feat(purga): Monday eliminado completamente del sistema

14 servicios eliminados, 7 controllers local-only, crons locales,
config sin MONDAY_*, frontend sin banners/avisos Monday.
552 tests API + 7 apps construidas.
This commit is contained in:
2026-09-11 11:47:17 +00:00
parent 64b01246d6
commit 89d95eb53b
130 changed files with 2018 additions and 14747 deletions

View File

@@ -1,13 +1,11 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
cambiarEstadoApartado,
cambiarEstadoApartadoEndpoint,
FASES_APARTADO_PERMITIDAS,
subirDocumentosApartado,
TIPOS_DOCUMENTO_APARTADO,
} from './apartados.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
@@ -25,9 +23,6 @@ const prismaMock = vi.hoisted(() => ({
documento: {
create: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
@@ -36,75 +31,7 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
/** Monday mockeado: registra cambios de columna y movimientos de grupo */
function mondayMock(grupoActual = 'topics') {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
movimientos: [] as { itemId: string; grupo: string }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async columnaIdPorTitulo(_boardId: string, titulo: string) {
return titulo === 'Estado' ? 'status' : null
},
async getItemConGrupo(itemId: string) {
return {
id: itemId,
name: 'Cliente Demo',
groupId: grupoActual,
column_values: [
{ id: 'status', text: (this.cambios.find((c) => c.columna === 'Estado')?.valor as string) ?? '', value: '{}' },
],
}
},
async moverItemAGrupo(itemId: string, grupo: string) {
this.movimientos.push({ itemId, grupo })
},
}
}
describe('cambiarEstadoApartado (POST /api/apartados/:id/estado)', () => {
it('cambia la fase (board 666 = MONDAY_BOARD_APARTADO de test)', async () => {
const monday = mondayMock()
await cambiarEstadoApartado(monday, 'apt-1', 'Alta Enkontrol')
expect(monday.cambios).toEqual([
{ boardId: '666', itemId: 'apt-1', columna: 'Estado', valor: 'Alta Enkontrol' },
])
// 'Alta Enkontrol' mapea al grupo topics: ya estaba ahí → no se mueve
expect(monday.movimientos).toEqual([])
})
it('Alta Enkontrol se queda en "Nuevos Clientes" (grupo topics)', async () => {
const monday = mondayMock()
await cambiarEstadoApartado(monday, 'apt-2', 'Alta Enkontrol')
expect(monday.movimientos).toEqual([])
})
it('Cancelacion Apartado mueve el item a "Historico"', async () => {
const monday = mondayMock()
await cambiarEstadoApartado(monday, 'apt-3', 'Cancelacion Apartado')
expect(monday.movimientos).toEqual([{ itemId: 'apt-3', grupo: 'group_mm6s2yyx' }])
})
it('las fases permitidas son los labels reales del board Apartado', () => {
expect(FASES_APARTADO_PERMITIDAS).toEqual([
'Pago Apartado',
'Alta Enkontrol',
'Entrego Documentacion',
'Cita Clientes',
'Contrato Enkontrol',
'Cancelacion Apartado',
])
})
it('los tipos de documento de la precarga son capturas obligatorias + generales', () => {
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'])
})
})
// ---------- Modo local (saludMonday caído / ids loc_*) ----------
describe('ruteo modo local (apartados)', () => {
describe('cambiarEstadoApartadoEndpoint (POST /api/apartados/:id/estado) — local', () => {
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
@@ -113,14 +40,10 @@ describe('ruteo modo local (apartados)', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.auditoriaLog.create.mockResolvedValue({})
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-local', ...data }))
prismaMock.documento.create.mockImplementation(async ({ data }) => ({ id: 'doc-1', ...data }))
prismaMock.syncPendiente.create.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
@@ -128,8 +51,7 @@ describe('ruteo modo local (apartados)', () => {
vi.restoreAllMocks()
})
it('cambiarEstadoApartadoEndpoint con id loc_*: fase local + CAMBIO_ETAPA encolado', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) // el id loc_* manda aunque Monday viva
it('con id loc_*: fase en la fila local', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_apt', board: 'APARTADO' })
const { res, json } = resFake()
@@ -142,14 +64,77 @@ describe('ruteo modo local (apartados)', () => {
where: { leadId: 'loc_apt' },
data: { etapa: 'Alta Enkontrol', pendienteSync: true },
})
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tipo: 'CAMBIO_ETAPA', referenciaLocal: 'loc_apt' }),
})
expect(json).toHaveBeenCalledWith({ itemId: 'loc_apt', estado: 'Alta Enkontrol' })
})
it('subirDocumentosApartado con Monday caído: nombre desde LeadCaptura local y checkbox encolado (CHECKBOX_DOC)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
it('con id heredado sin fila local: crea la sombra (mondayItemId)', async () => {
const { res, json } = resFake()
await cambiarEstadoApartadoEndpoint(
{ params: { itemId: '99887766' }, body: { estado: 'Cancelacion Apartado' } } as unknown as Request,
res,
)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ leadId: '99887766', board: 'APARTADO', mondayItemId: '99887766' }),
})
expect(json).toHaveBeenCalledWith({ itemId: '99887766', estado: 'Cancelacion Apartado' })
})
it('rechaza una fase fuera del vocabulario (zod 400)', async () => {
const { res } = resFake()
await expect(
cambiarEstadoApartadoEndpoint(
{ params: { itemId: 'loc_apt' }, body: { estado: 'Fase Inventada' } } as unknown as Request,
res,
),
).rejects.toThrow()
expect(prismaMock.leadCaptura.update).not.toHaveBeenCalled()
})
it('las fases permitidas son el vocabulario canónico del CRM (pipeline Enkontrol)', () => {
expect(FASES_APARTADO_PERMITIDAS).toEqual([
'Pago Apartado',
'Alta Enkontrol',
'Entrego Documentacion',
'Cita Clientes',
'Contrato Enkontrol',
'Firma Cliente',
'Cancelacion Apartado',
'Venta',
'Seguimiento',
'Integracion',
'Llamada Escrituras',
'X Escriturar',
'Banco/Infonavit',
'X Autorizar',
'Autorizado',
'Escriturado',
'Titulado',
'Cancelo',
])
})
it('los tipos de documento de la precarga son capturas obligatorias + generales', () => {
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'])
})
})
describe('subirDocumentosApartado (POST /api/apartados/:id/documentos) — solo local', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-local', ...data }))
prismaMock.documento.create.mockImplementation(async ({ data }) => ({ id: 'doc-1', ...data }))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('crea el cliente mínimo con el nombre de la fila local y guarda el documento', async () => {
// El apartado local nació del "Aparto" de la oportunidad: su fila tiene el nombre
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_apt', nombreLead: 'Luis Demo' })
const archivo = {
@@ -158,27 +143,46 @@ describe('ruteo modo local (apartados)', () => {
mimetype: 'application/pdf',
size: 1234,
} as Express.Multer.File
const { res, status } = resFake()
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
const res = { json, status } as unknown as Response
await subirDocumentosApartado(
{ params: { itemId: 'loc_apt' }, body: { tipo: 'CURP' }, files: [archivo], usuarioToken: { sub: 'u-1' } } as unknown as Request,
res,
)
// Cliente mínimo creado con el nombre de la fila local (no truena por obtenerNombreItem)
expect(prismaMock.cliente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ nombreCompleto: 'Luis Demo', mondayApartadoId: 'loc_apt' }),
})
expect(prismaMock.documento.create).toHaveBeenCalledWith({
data: expect.objectContaining({ clienteId: 'cli-local', tipo: 'CURP' }),
})
// El checkbox no se intenta en Monday: queda encolado para el worker
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'CHECKBOX_DOC',
payloadJson: JSON.stringify({ checkbox: 'CURP', itemId: 'loc_apt' }),
referenciaLocal: 'loc_apt',
}),
// Solo local: nada en la cola de réplica
expect(status).toHaveBeenCalledWith(201)
})
it('sin fila local usa "Cliente" como nombre y guarda igual', async () => {
const archivo = {
originalname: 'ine.pdf',
path: '/tmp/uploads/apartados/loc_x/OTRO_2.pdf',
mimetype: 'application/pdf',
size: 42,
} as Express.Multer.File
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
const res = { json, status } as unknown as Response
await subirDocumentosApartado(
{ params: { itemId: 'loc_x' }, body: {}, files: [archivo] } as unknown as Request,
res,
)
expect(prismaMock.cliente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ nombreCompleto: 'Cliente' }),
})
expect(prismaMock.documento.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tipo: 'OTRO' }),
})
expect(status).toHaveBeenCalledWith(201)
})

View File

@@ -5,41 +5,17 @@ import path from 'node:path'
import multer from 'multer'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { VOCABULARIO } from '../config/vocabulario.js'
import { prisma } from '../services/prisma.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { checkboxParaTipoDocumento } from './clientes.controller.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.service.js'
/**
* Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede
* mover desde los botones de "Mis apartados" en Mi día. Son los labels REALES
* de la columna status 'Estado' del board (verificados en Monday) — el
* frontend los manda tal cual.
* mover desde los botones de "Mis apartados" en Mi día. Es el vocabulario
* canónico del CRM (config/vocabulario.ts) — el frontend los manda tal cual.
*/
export const FASES_APARTADO_PERMITIDAS = [
'Pago Apartado',
'Alta Enkontrol',
'Entrego Documentacion',
'Cita Clientes',
'Contrato Enkontrol',
'Firma Cliente',
'Cancelacion Apartado',
'Venta',
'Seguimiento',
'Integracion',
'Llamada Escrituras',
'X Escriturar',
'Banco/Infonavit',
'X Autorizar',
'Autorizado',
'Escriturado',
'Titulado',
'Cancelo',
] as const
export const FASES_APARTADO_PERMITIDAS = VOCABULARIO.FASES_APARTADO
const cambiarEstadoSchema = z.object({
estado: z.enum(FASES_APARTADO_PERMITIDAS),
@@ -48,48 +24,18 @@ const cambiarEstadoSchema = z.object({
comentario: z.string().optional(),
})
type MondayApartados = Pick<
MondayService,
'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'
>
/**
* Cambia la fase del apartado (columna 'Estado') y mueve el item al grupo que
* corresponde (idempotente; el webhook de change haría lo mismo, pero así la
* UI responde al instante).
* Cambia la fase del apartado (etapa de la fila local) y encola el cambio para
* la réplica. SIEMPRE local: la fila LeadCaptura board=APARTADO es la verdad;
* ids heredados del CRM anterior sin fila crean sombra.
*/
export async function cambiarEstadoApartado(
monday: MondayApartados,
itemId: string,
estado: (typeof FASES_APARTADO_PERMITIDAS)[number],
): Promise<void> {
const boardApartado = config.monday.boards.apartado
await monday.cambiarValorColumna(boardApartado, itemId, 'Estado', estado)
await moverAGrupoSegunEstado(monday, boardApartado, itemId)
}
/** POST /api/apartados/:itemId/estado { estado } */
export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
// Modo local (id loc_* o Monday caído / fallback 502): fase en la fila local
// + CAMBIO_ETAPA encolado; ids Monday viejos sin fila crean sombra
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
} else {
try {
await cambiarEstadoApartado(mondayService, itemId, body.estado)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
usuarioId: req.usuarioToken?.sub,
sombra: true,
})
}
}
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
res.json({ itemId, estado: body.estado })
const detalle = body.motivo
? `${body.estado} — motivo: ${body.motivo}${body.subcategoria ? ` / ${body.subcategoria}` : ''}${body.comentario ? ` (${body.comentario})` : ''}`
@@ -114,7 +60,7 @@ const EXT_POR_MIME_APARTADO: Record<string, string> = {
'image/png': 'png',
}
// Storage propio: archiva por item de Monday (uploads/apartados/<itemId>/)
// Storage propio: archiva por apartado (uploads/apartados/<itemId>/)
const storageApartados = multer.diskStorage({
destination: (req, _file, cb) => {
const dir = path.join(config.uploadsDir, 'apartados', String(req.params['itemId']))
@@ -141,30 +87,18 @@ export const uploadDocumentosApartado = multer({
})
/**
* Cliente local ligado al apartado de Monday; si aún no existe (apartado
* creado sin pasar por el alta progresiva), se crea la ficha mínima para que
* los documentos tengan dónde vivir. El nombre se toma de Monday; si Monday
* está caído o el id es local (loc_*), se usa el de la fila LeadCaptura del
* apartado (board APARTADO, creada por el "Aparto" local) como fallback.
* Cliente local ligado al apartado; si aún no existe (apartado creado sin
* pasar por el alta progresiva), se crea la ficha mínima para que los
* documentos tengan dónde vivir. El nombre sale de la fila LeadCaptura del
* apartado (creada por el "Aparto" local o por la sombra de un item viejo).
*/
async function clienteDeApartado(itemId: string, capturadoPorId?: string) {
const existente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } })
if (existente) return existente
let nombre: string | null = null
if (!esIdLocal(itemId) && saludMonday.estaDisponible()) {
try {
nombre = await mondayService.obtenerNombreItem(itemId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo leer el nombre del apartado ${itemId} en Monday; se usa el nombre local:`, err)
}
}
if (nombre === null) {
const captura = await prisma.leadCaptura.findFirst({
where: { OR: [{ leadId: itemId }, { mondayItemId: itemId }] },
})
nombre = captura?.nombreLead ?? 'Cliente'
}
const captura = await prisma.leadCaptura.findFirst({
where: { OR: [{ leadId: itemId }, { mondayItemId: itemId }] },
})
const nombre = captura?.nombreLead ?? 'Cliente'
return prisma.cliente.create({
data: {
nombreCompleto: nombre,
@@ -180,9 +114,8 @@ async function clienteDeApartado(itemId: string, capturadoPorId?: string) {
/**
* POST /api/apartados/:itemId/documentos — multipart, campo `archivos` (hasta
* 10) + campo `tipo`. Los documentos se guardan en el CLIENTE ligado al
* apartado (Documento.clienteId); si el tipo tiene checkbox en el tablero
* Apartado (CURP/RFC/Comprobante), se prende en Monday.
* 10) + campo `tipo`. SOLO local: los documentos se guardan en el CLIENTE
* ligado al apartado (Documento.clienteId).
*/
export async function subirDocumentosApartado(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
@@ -204,24 +137,6 @@ export async function subirDocumentosApartado(req: Request, res: Response): Prom
}),
),
)
// Prende el checkbox del documento en el item de Apartado (si el tipo tiene
// checkbox); un fallo de Monday no rompe la subida: queda encolado
// (CHECKBOX_DOC) y el worker lo aplica al sincronizar.
const checkbox = checkboxParaTipoDocumento(tipo)
if (checkbox !== null) {
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId)
} else {
try {
await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err)
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId)
}
}
}
res.status(201).json(documentos)
}

View File

@@ -6,12 +6,12 @@ export async function estadoAutomatizaciones(_req: Request, res: Response): Prom
res.json(await automatizacionesService.estado())
}
/** POST /api/automatizaciones/round-robin/run — pasada manual de asignación */
/** POST /api/automatizaciones/round-robin/run — pasada manual de asignación (local) */
export async function correrRoundRobin(_req: Request, res: Response): Promise<void> {
res.json(await automatizacionesService.runRoundRobin())
res.json(await automatizacionesService.runRoundRobinLocal())
}
/** GET /api/automatizaciones/inactividad/preview — leads que serían recordados hoy (sin escribir) */
export async function previewInactividad(_req: Request, res: Response): Promise<void> {
res.json(await automatizacionesService.escaneoInactividad({ preview: true }))
res.json(await automatizacionesService.escaneoInactividadLocal({ preview: true }))
}

View File

@@ -1,356 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { contactoDesdeDatos, textoDireccion } from './clientes.controller.js'
// ---------- Mocks de módulo (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
usuario: {
findUnique: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
crearContacto: vi.fn(),
actualizarContacto: 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 }))
const reqFake = (body: unknown, params: Record<string, string> = {}): Request =>
({ body, params }) as unknown as Request
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const datosWizard = {
datosGenerales: {
nombreCompleto: 'Ana Demo Progreso',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
email: 'ana@correo.com',
celular: '6649990003',
estadoCivil: 'SOLTERO',
tipoVivienda: 'RENTADA',
tipoCompra: 'VIVIR_AHI',
fechaNacimiento: '1990-01-01',
},
empleo: { tipoEmpleo: 'ASALARIADO' },
viviendaCredito: {
proyecto: 'DIAGONAL',
domicilioFiscal: {
calle: 'Av. Siempre Viva',
numExt: '123',
numInt: '4',
colonia: 'Col. Centro',
cp: '22000',
municipio: 'Tijuana',
estado: 'Baja California',
},
},
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.actualizarContacto.mockResolvedValue(undefined)
mondayMock.crearApartado.mockResolvedValue('ap-1')
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
})
describe('textoDireccion (domicilio del wizard → columna Dirección)', () => {
it('arma calle con ext/int, colonia, CP, municipio y estado', () => {
expect(
textoDireccion({
calle: 'Av. Siempre Viva',
numExt: '123',
numInt: '4',
colonia: 'Col. Centro',
cp: '22000',
municipio: 'Tijuana',
estado: 'Baja California',
}),
).toBe('Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California')
})
it('omite las partes vacías y devuelve undefined si no hay nada', () => {
expect(textoDireccion({ calle: 'Calle Uno', municipio: 'Tijuana' })).toBe('Calle Uno, Tijuana')
expect(textoDireccion({})).toBeUndefined()
expect(textoDireccion({ calle: ' ' })).toBeUndefined()
})
})
describe('contactoDesdeDatos (estado del wizard → campos del Contacto)', () => {
it('deriva todos los campos mapeables presentes', () => {
const c = contactoDesdeDatos(datosWizard)
expect(c).toMatchObject({
nombre: 'Ana Demo Progreso',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
email: 'ana@correo.com',
telefono: '6649990003',
direccion: 'Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California',
estadoCivilLabel: 'Soltero',
viviendaActualLabel: 'Rentada',
propositoCompraLabel: 'Habitarla',
rangoEdadLabel: 'Entre 24 y 40',
fechaNacimiento: '1990-01-01',
actividadEconomicaLabel: 'Empleado',
})
})
it('solo incluye campos presentes (no inventa datos)', () => {
const c = contactoDesdeDatos({ datosGenerales: { nombreCompleto: 'Solo Nombre' } })
expect(c).toEqual({ nombre: 'Solo Nombre' })
})
it('sin domicilioFiscal usa el texto libre datosGenerales.direccion', () => {
const c = contactoDesdeDatos({
datosGenerales: { nombreCompleto: 'Ana', direccion: 'Calle Libre 1' },
})
expect(c.direccion).toBe('Calle Libre 1')
})
})
describe('iniciarProgreso (POST /api/clientes/progreso/iniciar)', () => {
it('crea el Cliente local y el Contacto en Monday con Estado PROSPECTO', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const { res, status, json } = resFake()
await iniciarProgreso(
reqFake({
datosGenerales: {
nombreCompleto: 'Ana Demo Progreso',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
celular: '6649990003',
},
}),
res,
)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: 'cont-1' })
expect(mondayMock.crearContacto).toHaveBeenCalledWith({
nombre: 'Ana Demo Progreso',
estadoLabel: 'PROSPECTO',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
telefono: '6649990003',
})
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayContactoId: 'cont-1' },
})
})
it('requiere solo datosGenerales.nombreCompleto', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const { res, status } = resFake()
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.crearContacto).toHaveBeenCalledWith({
nombre: 'Solo Nombre',
estadoLabel: 'PROSPECTO',
})
await expect(iniciarProgreso(reqFake({ datosGenerales: {} }), resFake().res)).rejects.toThrow()
})
it('fallo de Monday: crea el local igual y devuelve mondayContactoId null', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null })
})
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const req = reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } })
;(req as unknown as { usuarioToken: { sub: string } }).usuarioToken = { sub: 'usr-1' }
await iniciarProgreso(req, resFake().res)
expect(prismaMock.cliente.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }),
)
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), resFake().res)
expect(prismaMock.cliente.create).toHaveBeenLastCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }),
)
})
})
describe('actualizarProgreso (PATCH /api/clientes/progreso/:id)', () => {
it('deriva los campos presentes y actualiza el Contacto (incl. Dirección armada)', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' })
const { res, json } = resFake()
await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res)
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { payloadJson: JSON.stringify(datosWizard) },
})
expect(mondayMock.actualizarContacto).toHaveBeenCalledWith(
'cont-1',
expect.objectContaining({
nombre: 'Ana Demo Progreso',
direccion: 'Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California',
actividadEconomicaLabel: 'Empleado',
telefono: '6649990003',
}),
)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: true })
})
it('sin mondayContactoId responde mondaySync: false y no toca Monday', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: null })
const { res, json } = resFake()
await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res)
expect(mondayMock.actualizarContacto).not.toHaveBeenCalled()
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false })
})
it('fallo de Monday: 200 igual con mondaySync: false', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' })
mondayMock.actualizarContacto.mockRejectedValue(new Error('Monday caído'))
const { res, json } = resFake()
await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false })
})
it('rechaza un body que no sea objeto', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
await expect(actualizarProgreso(reqFake('no-objeto', { id: 'cli-1' }), resFake().res)).rejects.toThrow()
})
})
describe('completarProgreso (POST /api/clientes/progreso/:id/completar)', () => {
const altaCompleta = {
...datosWizard,
viviendaCredito: {
...datosWizard.viviendaCredito,
ubicacion: 'U-999-DEMO2',
valorVenta: 1500000,
enganche: 20000,
},
firma: { firmaDataUrl: 'data:image/png;base64,AAAA' },
leadMondayId: 'lead-9',
}
it('pasa el Contacto a CLIENTE, crea el Apartado y mueve el lead a Aparto (dedup)', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
const { prismaStateStore } = await import('../services/automatizaciones.service.js')
prismaMock.cliente.findUnique.mockResolvedValue({
id: 'cli-1',
mondayContactoId: 'cont-1',
mondayApartadoId: null,
})
const { res, status, json } = resFake()
await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.actualizarContacto).toHaveBeenCalledWith(
'cont-1',
expect.objectContaining({ nombre: 'Ana Demo Progreso', estadoLabel: 'CLIENTE' }),
)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
expect.objectContaining({
clienteNombre: 'Ana Demo Progreso',
unidadCodigo: 'U-999-DEMO2',
montoApartado: 20000,
estadoLabel: 'Pago Apartado',
}),
)
expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore)
expect(json.mock.calls[0]?.[0]).toMatchObject({
id: 'cli-1',
mondayContactoId: 'cont-1',
mondayApartadoId: 'ap-1',
})
})
it('el Apartado lleva la ficha completa: contacto, precio, Forma de Pago y Vendedor del capturista original', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({
id: 'cli-1',
mondayContactoId: 'cont-1',
mondayApartadoId: null,
capturadoPorId: 'usr-9', // quien inició el alta progresiva
})
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
const altaConCredito = {
...altaCompleta,
viviendaCredito: { ...altaCompleta.viviendaCredito, tiposCredito: ['COFI_FOVISSSTE'] },
}
const { res, status } = resFake()
await completarProgreso(reqFake(altaConCredito, { id: 'cli-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
expect.objectContaining({
clienteNombre: 'Ana Demo Progreso',
unidadCodigo: 'U-999-DEMO2',
telefono: '6649990003',
email: 'ana@correo.com',
curp: 'AEDA900101MBCXXX00',
precioFinal: 1500000,
enganche: 20000,
vendedorId: 12345,
formaPagoLabel: 'Cofi-Fovissste',
fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
}),
)
})
it('sin Contacto previo lo crea directo como CLIENTE (alta sin Monday al iniciar)', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({
id: 'cli-1',
mondayContactoId: null,
mondayApartadoId: null,
})
const { res, status } = resFake()
await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.actualizarContacto).not.toHaveBeenCalled()
expect(mondayMock.crearContacto).toHaveBeenCalledWith(
expect.objectContaining({ nombre: 'Ana Demo Progreso', estadoLabel: 'CLIENTE' }),
)
})
it('rechaza un payload incompleto (misma validación que POST /clientes)', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' })
await expect(
completarProgreso(
reqFake({ datosGenerales: { nombreCompleto: 'Sin curp ni proyecto' } }, { id: 'cli-1' }),
resFake().res,
),
).rejects.toThrow()
expect(prismaMock.cliente.update).not.toHaveBeenCalled()
})
it('404 si el progreso no existe', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue(null)
await expect(
completarProgreso(reqFake(altaCompleta, { id: 'no-existe' }), resFake().res),
).rejects.toThrow('Cliente no encontrado')
})
})

View File

@@ -1,45 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
actividadEconomicaLabel,
checkboxParaTipoDocumento,
perfilDemografico,
rangoEdadLabel,
} from './clientes.controller.js'
import { checkboxParaTipoDocumento, formaPagoLabel } from './clientes.controller.js'
// ---------- Mocks de módulo (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
leadCaptura: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
leadOportunidad: {
findUnique: vi.fn(),
},
usuario: {
findUnique: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
}))
const mondayMock = vi.hoisted(() => ({
crearContacto: vi.fn(),
crearApartado: vi.fn(),
}))
const funnelMock = vi.hoisted(() => ({
moverAApartoConDedup: vi.fn(),
$transaction: 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', () => {
describe('checkboxParaTipoDocumento (tipos de documento del expediente)', () => {
it('mapea los tipos con checkbox al nombre del checkbox', () => {
expect(checkboxParaTipoDocumento('CURP')).toBe('CURP')
expect(checkboxParaTipoDocumento('RFC')).toBe('RFC')
expect(checkboxParaTipoDocumento('CONSTANCIA_RFC')).toBe('RFC')
@@ -59,50 +54,26 @@ describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', ()
})
})
describe('rangoEdadLabel', () => {
const hoy = new Date('2026-08-22T12:00:00')
it('deriva el rango del CRM a partir de la fecha de nacimiento', () => {
expect(rangoEdadLabel('2003-01-01', hoy)).toBe('Menor de 24') // 23 años
expect(rangoEdadLabel('2002-08-22', hoy)).toBe('Entre 24 y 40') // cumple 24 hoy
expect(rangoEdadLabel('1990-05-10', hoy)).toBe('Entre 24 y 40')
expect(rangoEdadLabel('1986-08-21', hoy)).toBe('Entre 24 y 40') // 40 años: límite inclusivo
expect(rangoEdadLabel('1985-01-01', hoy)).toBe('Mayor de 40')
describe('formaPagoLabel (tipos de crédito del alta)', () => {
it('mapea los 8 valores del enum del formato', async () => {
const { formaPagoLabel } = await import('./clientes.controller.js')
expect(formaPagoLabel(['INFONAVIT'])).toBe('Infonavit')
expect(formaPagoLabel(['APOYO_INFONAVIT'])).toBe('Apoyo Infonavit')
expect(formaPagoLabel(['COFINAVIT'])).toBe('Cofinavit')
expect(formaPagoLabel(['SEGUNDO_CREDITO'])).toBe('Segundo crédito')
expect(formaPagoLabel(['CONTADO'])).toBe('Contado')
expect(formaPagoLabel(['FOVISSSTE'])).toBe('Fovissste')
expect(formaPagoLabel(['COFI_FOVISSSTE'])).toBe('Cofi-Fovissste')
expect(formaPagoLabel(['BANCARIO'])).toBe('Bancario')
})
it('devuelve undefined con fechas inválidas o ausentes', () => {
expect(rangoEdadLabel(undefined, hoy)).toBeUndefined()
expect(rangoEdadLabel('no-es-fecha', hoy)).toBeUndefined()
expect(rangoEdadLabel('2999-01-01', hoy)).toBeUndefined()
})
})
describe('perfilDemografico (alta → Contacto Monday)', () => {
it('mapea los valores del formato a los labels del CRM', () => {
const perfil = perfilDemografico({
estadoCivil: 'UNION_LIBRE',
tipoVivienda: 'FAMILIAR',
tipoCompra: 'VIVIR_AHI',
fechaNacimiento: '1990-05-10',
})
expect(perfil.estadoCivilLabel).toBe('Union Libre')
expect(perfil.viviendaActualLabel).toBe('Comparte con familia')
expect(perfil.propositoCompraLabel).toBe('Habitarla')
expect(perfil.rangoEdadLabel).toBe('Entre 24 y 40')
expect(perfil.fechaNacimiento).toBe('1990-05-10')
})
it('omite campos vacíos o desconocidos (no inventa datos)', () => {
expect(perfilDemografico({})).toEqual({})
expect(perfilDemografico({ estadoCivil: '', tipoVivienda: 'OTRA_COSA' })).toEqual({})
})
})
describe('actividadEconomicaLabel', () => {
it('solo ASALARIADO tiene equivalencia inequívoca (Empleado)', () => {
expect(actividadEconomicaLabel('ASALARIADO')).toBe('Empleado')
expect(actividadEconomicaLabel('INDEPENDIENTE')).toBeUndefined() // ambiguo: no se adivina
expect(actividadEconomicaLabel(undefined)).toBeUndefined()
it('toma el PRIMERO reconocido del arreglo y tolera desconocidos', async () => {
const { formaPagoLabel } = await import('./clientes.controller.js')
expect(formaPagoLabel(['OTRA_COSA', 'CONTADO'])).toBe('Contado')
expect(formaPagoLabel(['OTRA_COSA'])).toBeUndefined()
expect(formaPagoLabel([])).toBeUndefined()
expect(formaPagoLabel(undefined)).toBeUndefined()
expect(formaPagoLabel('CONTADO')).toBeUndefined() // no es arreglo
})
})
@@ -125,53 +96,141 @@ describe('crearClienteSchema', () => {
})
})
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' },
}
// ---------- Alta local (POST /api/clientes) ----------
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 }
}
const altaBase = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
viviendaCredito: { proyecto: 'SENDA' },
}
const reqFake = (body: unknown, params: Record<string, string> = {}, usuarioToken?: { sub: string }): Request =>
({ body, params, usuarioToken } as unknown as Request)
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const oportunidadLocal = {
leadId: 'loc_op_9',
board: 'OPORTUNIDADES',
nombreLead: 'Lead Prueba',
usuarioId: 'u-1',
telefono: '4771234567',
email: null,
desarrollo: 'SENDA',
modelo: 'SOHO',
datosJson: null,
}
describe('crearCliente (POST /api/clientes) — alta local', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado
mondayMock.crearContacto.mockResolvedValue('cont-1')
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.leadCaptura.findUnique.mockResolvedValue(oportunidadLocal)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
it('con leadMondayId mueve la oportunidad a Aparto (con dedup) antes de replicar en Monday', async () => {
it('201: persiste el Cliente en SQLite y responde mondayContactoId null', 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)
const { res, status, json } = resFake()
await crearCliente(reqFake(altaBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore)
expect(prismaMock.cliente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ nombreCompleto: 'Cliente Demo', proyecto: 'SENDA' }),
})
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null })
})
it('con leadMondayId mueve la oportunidad a Aparto y crea el apartado local (dedup)', async () => {
const { crearCliente } = await import('./clientes.controller.js')
// El id del picker es una oportunidad directa (modelo nuevo)
prismaMock.leadOportunidad.findUnique
.mockResolvedValueOnce({ oportunidadId: 'loc_op_9' }) // buscarPorOportunidadId
.mockResolvedValue(null)
const { res, status, json } = resFake()
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_op_9' }), res)
expect(status).toHaveBeenCalledWith(201)
// Apartado local creado (board APARTADO, primer paso del pipeline) y encolado
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado' }),
})
// La ficha del cliente queda ligada al apartado local
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayApartadoId: expect.stringMatching(/^loc_/) },
})
expect(json.mock.calls[0]?.[0]).toMatchObject({
id: 'cli-1',
mondayContactoId: null,
mondayApartadoId: expect.stringMatching(/^loc_/),
})
})
it('con leadMondayId de un lead histórico resuelve la oportunidad vía mapping', async () => {
const { crearCliente } = await import('./clientes.controller.js')
prismaMock.leadOportunidad.findUnique
.mockResolvedValueOnce(null) // no es oportunidad directa
.mockResolvedValueOnce({ oportunidadId: 'loc_op_9' }) // mapping lead → oportunidad
const { res, status } = resFake()
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_lead_1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'APARTADO' }),
})
})
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()
expect(prismaMock.leadOportunidad.findUnique).not.toHaveBeenCalled()
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
})
it('con apartadoMondayId (botón "Alta Enkontrol" desde Mis apartados) LIGA ese apartado sin crear otro', async () => {
const { crearCliente } = await import('./clientes.controller.js')
const { res, status, json } = resFake()
await crearCliente(reqFake({ ...altaBase, apartadoMondayId: 'loc_apt_existente' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayApartadoId: 'loc_apt_existente' },
})
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayApartadoId: 'loc_apt_existente' })
})
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'))
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_9' })
prismaMock.leadCaptura.findUnique.mockResolvedValue(null) // apartar → NotFoundError
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const { res, status, json } = resFake()
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res)
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_op_9' }), res)
espia.mockRestore()
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: 'cont-1' })
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayApartadoId: null })
})
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
@@ -187,193 +246,159 @@ describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () =>
})
})
describe('formaPagoLabel (tipos de crédito del alta → dropdown Forma de Pago)', () => {
it('mapea los 8 valores del enum del formato a los labels sembrados en Monday', async () => {
const { formaPagoLabel } = await import('./clientes.controller.js')
expect(formaPagoLabel(['INFONAVIT'])).toBe('Infonavit')
expect(formaPagoLabel(['APOYO_INFONAVIT'])).toBe('Apoyo Infonavit')
expect(formaPagoLabel(['COFINAVIT'])).toBe('Cofinavit')
expect(formaPagoLabel(['SEGUNDO_CREDITO'])).toBe('Segundo crédito')
expect(formaPagoLabel(['CONTADO'])).toBe('Contado')
expect(formaPagoLabel(['FOVISSSTE'])).toBe('Fovissste')
expect(formaPagoLabel(['COFI_FOVISSSTE'])).toBe('Cofi-Fovissste')
expect(formaPagoLabel(['BANCARIO'])).toBe('Bancario')
})
it('toma el PRIMERO reconocido del arreglo y tolera desconocidos', async () => {
const { formaPagoLabel } = await import('./clientes.controller.js')
expect(formaPagoLabel(['OTRA_COSA', 'CONTADO'])).toBe('Contado')
expect(formaPagoLabel(['OTRA_COSA'])).toBeUndefined()
expect(formaPagoLabel([])).toBeUndefined()
expect(formaPagoLabel(undefined)).toBeUndefined()
expect(formaPagoLabel('CONTADO')).toBeUndefined() // no es arreglo
})
})
describe('crearCliente — Apartado con ficha completa', () => {
const altaConVenta = {
datosGenerales: {
nombreCompleto: 'Cliente Demo',
curp: 'CURP123',
rfc: 'RFC123',
email: 'cli@correo.com',
celular: '6641112233',
},
viviendaCredito: {
proyecto: 'SENDA',
ubicacion: 'U-101',
valorVenta: 1500000,
enganche: 20000,
tiposCredito: ['INFONAVIT'],
},
}
const reqFake = (body: unknown, usuarioToken?: { sub: string }): Request =>
({ body, usuarioToken } as unknown as Request)
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
describe('alta progresiva — local', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null)
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.crearApartado.mockResolvedValue('ap-1')
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findUnique.mockResolvedValue(oportunidadLocal)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_9' })
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
it('el Apartado lleva contacto, precio, enganche, Fecha Compra (hoy) y Forma de Pago', async () => {
const { crearCliente } = await import('./clientes.controller.js')
it('iniciarProgreso crea el alta en progreso (sin réplica externa)', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const { res, status, json } = resFake()
await crearCliente(reqFake(altaConVenta), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
expect.objectContaining({
clienteNombre: 'Cliente Demo',
unidadCodigo: 'U-101',
montoApartado: 20000,
estadoLabel: 'Pago Apartado',
telefono: '6641112233',
email: 'cli@correo.com',
curp: 'CURP123',
precioFinal: 1500000,
enganche: 20000,
formaPagoLabel: 'Infonavit',
fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
await iniciarProgreso(
reqFake({
datosGenerales: {
nombreCompleto: 'Ana Demo Progreso',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
celular: '6649990003',
},
}),
res,
)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayApartadoId: 'ap-1' })
})
it('con capturista vinculado a Monday, el Apartado lo lleva como Vendedor (people)', async () => {
const { crearCliente } = await import('./clientes.controller.js')
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
await crearCliente(reqFake(altaConVenta, { sub: 'usr-1' }), resFake().res)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: 12345 }))
})
it('sin capturista vinculado, el Apartado se crea sin Vendedor', async () => {
const { crearCliente } = await import('./clientes.controller.js')
await crearCliente(reqFake(altaConVenta), resFake().res)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: undefined }))
})
})
describe('réplica diferida (encolado cuando Monday falla)', () => {
const altaConVenta = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6641112233' },
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-101', valorVenta: 1500000, enganche: 20000 },
}
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 }
}
function tiposEncolados(): string[] {
return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo)
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.syncPendiente.create.mockResolvedValue({})
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('falla crearContacto: 201 con ids null y CREAR_CONTACTO + CREAR_APARTADO encolados', async () => {
const { crearCliente } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await crearCliente(reqFake(altaConVenta), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null })
expect(tiposEncolados()).toEqual(['CREAR_CONTACTO', 'CREAR_APARTADO'])
// El payload del Contacto lleva los datos del alta (el worker no necesita nada más)
const llamadaContacto = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as {
data: { payloadJson: string; referenciaLocal: string }
}
expect(JSON.parse(llamadaContacto.data.payloadJson)['datos']).toMatchObject({
nombre: 'Cliente Demo',
estadoLabel: 'CLIENTE',
curp: 'CURP123',
})
expect(llamadaContacto.data.referenciaLocal).toBe('cli-1')
// El del Apartado referencia al cliente para ligar mondayApartadoId al sincronizar
const llamadaApartado = prismaMock.syncPendiente.create.mock.calls[1]?.[0] as {
data: { payloadJson: string; referenciaLocal: string }
}
expect(JSON.parse(llamadaApartado.data.payloadJson)).toMatchObject({
clienteId: 'cli-1',
datos: { clienteNombre: 'Cliente Demo', unidadCodigo: 'U-101', estadoLabel: 'Pago Apartado' },
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null })
expect(prismaMock.cliente.create).toHaveBeenCalledWith({
data: expect.objectContaining({ nombreCompleto: 'Ana Demo Progreso' }),
})
})
it('falla solo el Apartado: conserva mondayContactoId (lo persiste) y encola CREAR_APARTADO', async () => {
const { crearCliente } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.crearApartado.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await crearCliente(reqFake(altaConVenta), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayContactoId: 'cont-1', mondayApartadoId: null })
expect(tiposEncolados()).toEqual(['CREAR_APARTADO']) // el Contacto ya existe: no se duplica
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayContactoId: 'cont-1', mondayApartadoId: null },
})
})
it('falla moverAApartoConDedup: encola APARTO_OPORTUNIDAD con el leadMondayId', async () => {
const { crearCliente } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.crearApartado.mockResolvedValue('ap-1')
funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído'))
it('iniciarProgreso requiere solo datosGenerales.nombreCompleto', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const { res, status } = resFake()
await crearCliente(reqFake({ ...altaConVenta, leadMondayId: 'lead-9' }), res)
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }), res)
expect(status).toHaveBeenCalledWith(201)
expect(tiposEncolados()).toEqual(['APARTO_OPORTUNIDAD'])
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'APARTO_OPORTUNIDAD',
payloadJson: JSON.stringify({ leadId: 'lead-9' }),
referenciaLocal: 'lead-9',
}),
await expect(iniciarProgreso(reqFake({ datosGenerales: {} }), resFake().res)).rejects.toThrow()
})
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const req = reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } })
;(req as unknown as { usuarioToken: { sub: string } }).usuarioToken = { sub: 'usr-1' }
await iniciarProgreso(req, resFake().res)
expect(prismaMock.cliente.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }),
)
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), resFake().res)
expect(prismaMock.cliente.create).toHaveBeenLastCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }),
)
})
it('actualizarProgreso guarda el avance del wizard localmente (mondaySync false)', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
const datosWizard = { datosGenerales: { nombreCompleto: 'Ana', celular: '6649990003' } }
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' })
const { res, json } = resFake()
await actualizarProgreso({ body: datosWizard, params: { id: 'cli-1' } } as unknown as Request, res)
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { payloadJson: JSON.stringify(datosWizard) },
})
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false })
})
it('actualizarProgreso con id desconocido → 404', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue(null)
await expect(
actualizarProgreso({ body: {}, params: { id: 'no-existe' } } as unknown as Request, resFake().res),
).rejects.toThrow('Cliente no encontrado')
})
it('actualizarProgreso rechaza un body que no sea objeto', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' })
await expect(
actualizarProgreso({ body: 'no-objeto', params: { id: 'cli-1' } } as unknown as Request, resFake().res),
).rejects.toThrow()
})
it('completarProgreso cierra el alta y mueve la oportunidad del funnel a Aparto', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({
id: 'cli-1',
capturadoPorId: 'usr-9',
mondayApartadoId: null,
})
const altaCompleta = {
datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0' },
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 },
leadMondayId: 'loc_op_9',
}
const { res, status, json } = resFake()
await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: expect.objectContaining({ proyecto: 'SENDA', unidadCodigo: 'U-999' }),
})
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado' }),
})
// La ficha del apartado local se enriquece con lo capturado en el alta
const enriquecimiento = prismaMock.leadCaptura.update.mock.calls.find(
(c) => (c[0] as { where: { leadId: string } }).where.leadId !== 'loc_op_9',
)
expect(enriquecimiento).toBeDefined()
const datos = JSON.parse(
(enriquecimiento![0] as { data: { datosJson: string } }).data.datosJson,
) as Record<string, unknown>
expect(datos).toMatchObject({ unidadCodigo: 'U-999', precioFinal: 1500000, enganche: 20000, estadoLabel: 'Pago Apartado' })
expect(json.mock.calls[0]?.[0]).toMatchObject({
id: 'cli-1',
mondayContactoId: null,
mondayApartadoId: expect.stringMatching(/^loc_/),
})
})
it('completarProgreso rechaza un payload incompleto (misma validación que POST /clientes)', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' })
await expect(
completarProgreso(
reqFake({ datosGenerales: { nombreCompleto: 'Sin curp ni proyecto' } }, { id: 'cli-1' }),
resFake().res,
),
).rejects.toThrow()
expect(prismaMock.cliente.update).not.toHaveBeenCalled()
})
it('completarProgreso 404 si el progreso no existe', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue(null)
await expect(
completarProgreso(reqFake(altaBase, { id: 'no-existe' }), resFake().res),
).rejects.toThrow('Cliente no encontrado')
})
})

View File

@@ -5,13 +5,8 @@ import { randomUUID } from 'node:crypto'
import multer from 'multer'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type DatosApartado, type DatosContacto } from '../services/monday.service.js'
import { funnelService } from '../services/funnel.service.js'
import { prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js'
import { encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { crmLocal } from '../services/crm-local.service.js'
import { NotFoundError, BadRequestError } from '../utils/errors.js'
import * as auditoria from '../services/auditoria.service.js'
@@ -27,7 +22,7 @@ export const crearClienteSchema = z
email: z.string().optional(),
celular: z.string().optional(),
direccion: z.string().optional(),
// Perfil demográfico (opcionales; se replican al Contacto en Monday)
// Perfil demográfico (opcionales; viajan en el payload del alta)
fechaNacimiento: z.string().optional(),
estadoCivil: z.string().optional(),
tipoVivienda: z.string().optional(),
@@ -48,90 +43,9 @@ export const crearClienteSchema = z
})
.passthrough() // el resto del payload pasa tal cual a payloadJson
// ---------- Mapeos al vocabulario del CRM FRASA (lógica pura, testeable) ----------
const ESTADO_CIVIL_LABEL: Record<string, string> = {
SOLTERO: 'Soltero',
CASADO: 'Casado',
UNION_LIBRE: 'Union Libre',
DIVORCIADO: 'Divorciado',
VIUDO: 'Viudo',
}
const VIVIENDA_ACTUAL_LABEL: Record<string, string> = {
PRESTADA: 'Prestada',
RENTADA: 'Rentada',
FAMILIAR: 'Comparte con familia',
PROPIA: 'Propia',
}
const PROPOSITO_COMPRA_LABEL: Record<string, string> = {
INVERSION: 'Inversion',
VIVIR_AHI: 'Habitarla',
}
// El formato de alta solo permite inferir 'Empleado' (ASALARIADO); un
// INDEPENDIENTE puede ser Comerciante o Empresario: no se adivina.
const ACTIVIDAD_ECONOMICA_LABEL: Record<string, string> = {
ASALARIADO: 'Empleado',
}
/** Label del dropdown "Rango de Edad" derivado de la fecha de nacimiento */
export function rangoEdadLabel(fechaNacimiento: string | undefined, hoy: Date = new Date()): string | undefined {
if (!fechaNacimiento) return undefined
const nacimiento = new Date(`${fechaNacimiento.slice(0, 10)}T00:00:00`)
if (Number.isNaN(nacimiento.getTime())) return undefined
let edad = hoy.getFullYear() - nacimiento.getFullYear()
const aunNoCumple =
hoy.getMonth() < nacimiento.getMonth() ||
(hoy.getMonth() === nacimiento.getMonth() && hoy.getDate() < nacimiento.getDate())
if (aunNoCumple) edad--
if (edad < 0 || edad > 120) return undefined
if (edad < 24) return 'Menor de 24'
if (edad <= 40) return 'Entre 24 y 40'
return 'Mayor de 40'
}
/** Labels demográficos del Contacto a partir del payload del formato de alta */
export function perfilDemografico(dg: {
estadoCivil?: string | undefined
tipoVivienda?: string | undefined
tipoCompra?: string | undefined
fechaNacimiento?: string | undefined
}): {
estadoCivilLabel?: string | undefined
viviendaActualLabel?: string | undefined
propositoCompraLabel?: string | undefined
rangoEdadLabel?: string | undefined
fechaNacimiento?: string | undefined
} {
const limpio = (v: string | undefined): string => (v ?? '').trim().toUpperCase()
const fecha = /^\d{4}-\d{2}-\d{2}/.test(dg.fechaNacimiento ?? '')
? dg.fechaNacimiento!.slice(0, 10)
: undefined
return {
...(ESTADO_CIVIL_LABEL[limpio(dg.estadoCivil)] !== undefined
? { estadoCivilLabel: ESTADO_CIVIL_LABEL[limpio(dg.estadoCivil)] }
: {}),
...(VIVIENDA_ACTUAL_LABEL[limpio(dg.tipoVivienda)] !== undefined
? { viviendaActualLabel: VIVIENDA_ACTUAL_LABEL[limpio(dg.tipoVivienda)] }
: {}),
...(PROPOSITO_COMPRA_LABEL[limpio(dg.tipoCompra)] !== undefined
? { propositoCompraLabel: PROPOSITO_COMPRA_LABEL[limpio(dg.tipoCompra)] }
: {}),
...(rangoEdadLabel(fecha) !== undefined ? { rangoEdadLabel: rangoEdadLabel(fecha) } : {}),
...(fecha !== undefined ? { fechaNacimiento: fecha } : {}),
}
}
/** Label de "Actividad Economica" derivado del tipo de empleo del formato */
export function actividadEconomicaLabel(tipoEmpleo: string | undefined): string | undefined {
return ACTIVIDAD_ECONOMICA_LABEL[(tipoEmpleo ?? '').trim().toUpperCase()]
}
/**
* Checkbox del tablero Apartado que corresponde a un tipo de documento.
* INE y el resto no tienen checkbox (devuelve null).
* Checkbox que corresponde a un tipo de documento en el expediente del
* apartado. INE y el resto no tienen checkbox (devuelve null).
*/
export function checkboxParaTipoDocumento(tipo: string): string | null {
switch (tipo.trim().toUpperCase()) {
@@ -162,33 +76,7 @@ const hoyMas = (dias: number): string => {
return `${d.getFullYear()}-${m}-${dia}`
}
// ---------- Helpers compartidos (alta directa y alta progresiva) ----------
/**
* Si el alta viene de una afluencia del funnel (la app alta-clientes manda
* leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto'.
* El id puede ser una oportunidad directa (modelo nuevo: la afluencia ES la
* oportunidad, fuente 'oportunidad' del picker) o un lead histórico del
* tablero Leads; moverAApartoConDedup resuelve ambos vía LeadOportunidad.
* moverAApartoConDedup escribe el dedup `apartado-creado:<opId>` ANTES del
* cambio de etapa, para que el webhook/cron no cree un Apartado duplicado.
* Un fallo aquí no rompe el alta.
*/
async function moverLeadAApartoSiAplica(body: Record<string, unknown>): Promise<void> {
const leadMondayId = body['leadMondayId']
if (typeof leadMondayId === 'string' && leadMondayId !== '') {
try {
await funnelService.moverAApartoConDedup(leadMondayId, prismaStateStore)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadMondayId}:`, err)
// El worker lo reintenta cuando Monday vuelva (con el mismo dedup)
await encolarSyncSeguro('APARTO_OPORTUNIDAD', { leadId: leadMondayId }, leadMondayId)
}
}
}
/** Label del dropdown "Forma de Pago" (tablero Apartado) a partir del enum del formato de alta */
/** Label de "Forma de Pago" a partir del enum del formato de alta */
const FORMA_PAGO_LABEL: Record<string, string> = {
INFONAVIT: 'Infonavit',
APOYO_INFONAVIT: 'Apoyo Infonavit',
@@ -201,9 +89,8 @@ const FORMA_PAGO_LABEL: Record<string, string> = {
}
/**
* Label de "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo
* del wizard; se toma el PRIMERO reconocido — la columna del board es de una
* sola forma de pago). undefined si no hay ninguno reconocible.
* "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo del
* wizard; se toma el PRIMERO reconocido). undefined si no hay ninguno.
*/
export function formaPagoLabel(tiposCredito: unknown): string | undefined {
if (!Array.isArray(tiposCredito)) return undefined
@@ -214,80 +101,54 @@ export function formaPagoLabel(tiposCredito: unknown): string | undefined {
return undefined
}
// ---------- Helpers locales compartidos (alta directa y alta progresiva) ----------
const apartadoLigadoDeBody = (body: Record<string, unknown>): string | null => {
const id = body['apartadoMondayId']
return typeof id === 'string' && id !== '' ? id : null
}
/**
* Datos del item en el tablero Apartado (pipeline Enkontrol) para el alta;
* null si no hay valor de venta (no aplica apartado). Lo usan tanto la réplica
* directa (crearApartadoSiAplica) como el encolado diferido (CREAR_APARTADO).
* Si el alta viene de una afluencia del funnel (la app alta-clientes manda
* leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto' y se
* crea el apartado local (crmLocal.apartar; idempotente por dedup). El id
* puede ser una oportunidad directa (modelo nuevo: la afluencia ES la
* oportunidad, fuente 'oportunidad' del picker) o un lead histórico: se
* resuelve ambos vía LeadOportunidad, como hacía moverAApartoConDedup.
* Devuelve el id de la fila de apartado local creada (null si no aplica).
* Un fallo aquí no rompe el alta.
*/
async function datosApartadoDeAlta(
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
valorVenta?: string | number | undefined
enganche?: string | number | undefined
tiposCredito?: unknown
},
async function moverLeadAApartoSiAplica(
body: Record<string, unknown>,
capturadoPorId: string | null,
): Promise<DatosApartado | null> {
const valorVenta = aNumero(vc.valorVenta)
if (valorVenta <= 0) return null
const enganche = aNumero(vc.enganche)
// Vendedor del apartado: el usuario Monday vinculado de quien capturó el alta
let vendedorId: number | undefined
if (capturadoPorId !== null) {
const capturista = await prisma.usuario.findUnique({
where: { id: capturadoPorId },
select: { mondayUserId: true },
): Promise<string | null> {
const leadId = body['leadMondayId']
if (typeof leadId !== 'string' || leadId === '') return null
try {
const comoOportunidad = await prisma.leadOportunidad.findUnique({
where: { oportunidadId: leadId },
})
vendedorId = capturista?.mondayUserId ?? undefined
const mapping =
comoOportunidad ??
(await prisma.leadOportunidad.findUnique({ where: { leadId } }))
const oportunidadId = comoOportunidad?.oportunidadId ?? mapping?.oportunidadId ?? null
if (oportunidadId === null) return null
const apartado = await crmLocal.apartar(oportunidadId, capturadoPorId ?? undefined)
return apartado.leadId
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadId}:`, err)
return null
}
return {
clienteNombre: dg.nombreCompleto,
unidadCodigo: vc.ubicacion ?? '',
...(enganche > 0 ? { montoApartado: enganche } : {}),
fechaApartado: hoyMas(0),
fechaLimite: hoyMas(15),
estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
telefono: dg.celular,
email: dg.email,
curp: dg.curp,
precioFinal: valorVenta,
enganche: enganche > 0 ? enganche : undefined,
fechaCompra: hoyMas(0),
vendedorId,
formaPagoLabel: formaPagoLabel(vc.tiposCredito),
}
}
/** Item en el tablero Apartado (pipeline Enkontrol) con la ficha completa; solo si hay valor de venta */
async function crearApartadoSiAplica(
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
valorVenta?: string | number | undefined
enganche?: string | number | undefined
tiposCredito?: unknown
},
capturadoPorId: string | null,
): Promise<string | null> {
const datos = await datosApartadoDeAlta(dg, vc, capturadoPorId)
if (datos === null) return null
return mondayService.crearApartado(datos)
}
/**
* Réplica diferida del alta (modo local): cuando la réplica Monday falla a
* media operación, encola en SyncPendiente solo lo que no alcanzó a crearse
* (Contacto y/o Apartado) para que el worker lo aplique cuando Monday vuelva.
* Si el alta venía ligada a un apartado Monday existente (apartadoMondayId),
* no se encola la creación: el item ya existe (solo falló enriquecerlo).
* Devuelve el mondayApartadoId efectivo tras el fallback.
* Enriquece la fila de apartado local con lo capturado en el alta (unidad,
* precio, enganche, forma de pago, contacto): el "Alta Enkontrol" desde un
* apartado encuentra así la ficha completa al prellenar. Best-effort.
*/
async function encolarReplicasPendientesCliente(
clienteId: string,
datosContacto: DatosContacto,
mondayContactoId: string | null,
mondayApartadoId: string | null,
async function enriquecerApartadoLocal(
apartadoId: string,
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
@@ -295,57 +156,60 @@ async function encolarReplicasPendientesCliente(
enganche?: string | number | undefined
tiposCredito?: unknown
},
capturadoPorId: string | null,
apartadoMondayId: unknown,
): Promise<string | null> {
if (mondayContactoId === null) {
await encolarSyncSeguro('CREAR_CONTACTO', { datos: datosContacto }, clienteId)
): Promise<void> {
try {
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: apartadoId } })
if (!captura) return
let datos: Record<string, unknown> = {}
if (captura.datosJson) {
try { datos = JSON.parse(captura.datosJson) as Record<string, unknown> } catch { /* ignorar */ }
}
const valorVenta = aNumero(vc.valorVenta)
const enganche = aNumero(vc.enganche)
datos['unidadCodigo'] = vc.ubicacion ?? datos['unidadCodigo'] ?? ''
if (valorVenta > 0) datos['precioFinal'] = valorVenta
if (enganche > 0) {
datos['enganche'] = enganche
datos['montoApartado'] = enganche
}
datos['estadoLabel'] = 'Pago Apartado'
datos['fechaApartado'] = datos['fechaApartado'] ?? hoyMas(0)
datos['fechaLimite'] = datos['fechaLimite'] ?? hoyMas(15)
datos['fechaCompra'] = hoyMas(0)
const formaPago = formaPagoLabel(vc.tiposCredito)
if (formaPago !== undefined) datos['formaPagoLabel'] = formaPago
if (dg.celular !== undefined && dg.celular !== '') datos['telefono'] = dg.celular
if (dg.email !== undefined && dg.email !== '') datos['email'] = dg.email
if (dg.curp !== undefined && dg.curp !== '') datos['curp'] = dg.curp
await prisma.leadCaptura.update({
where: { leadId: apartadoId },
data: {
datosJson: JSON.stringify(datos),
...(dg.celular !== undefined && dg.celular !== '' && captura.telefono === null ? { telefono: dg.celular } : {}),
...(dg.email !== undefined && dg.email !== '' && captura.email === null ? { email: dg.email } : {}),
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo enriquecer el apartado local ${apartadoId}:`, err)
}
if (mondayApartadoId !== null) return mondayApartadoId
const existente = typeof apartadoMondayId === 'string' && apartadoMondayId !== '' ? apartadoMondayId : null
if (existente !== null) return existente
const datosApartado = await datosApartadoDeAlta(dg, vc, capturadoPorId)
if (datosApartado !== null) {
await encolarSyncSeguro('CREAR_APARTADO', { datos: datosApartado, clienteId }, clienteId)
}
return null
}
/**
* Apartado Monday del alta: con apartadoMondayId (alta arrancada desde un
* apartado existente, botón "Alta Enkontrol" de Mis apartados) se
* LIGA y enriquece ese item; sin él, se crea uno nuevo como siempre.
* Liga el apartado del alta a la ficha del Cliente (mondayApartadoId conserva
* el nombre histórico: es el id de la fila de apartado, local o heredado).
* Best-effort: un fallo no rompe el alta.
*/
async function apartadoDeAlta(
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
valorVenta?: string | number | undefined
enganche?: string | number | undefined
tiposCredito?: unknown
},
capturadoPorId: string | null,
apartadoMondayId: unknown,
): Promise<string | null> {
const existente = typeof apartadoMondayId === 'string' && apartadoMondayId !== '' ? apartadoMondayId : null
if (existente === null) return crearApartadoSiAplica(dg, vc, capturadoPorId)
const valorVenta = aNumero(vc.valorVenta)
const enganche = aNumero(vc.enganche)
// Solo se mandan los campos con valor (exactOptionalPropertyTypes)
await mondayService.actualizarApartado(existente, {
clienteNombre: dg.nombreCompleto,
...(vc.ubicacion !== undefined && vc.ubicacion !== '' ? { unidadCodigo: vc.ubicacion } : {}),
...(dg.celular !== undefined && dg.celular !== '' ? { telefono: dg.celular } : {}),
...(dg.email !== undefined && dg.email !== '' ? { email: dg.email } : {}),
...(dg.curp !== undefined && dg.curp !== '' ? { curp: dg.curp } : {}),
...(valorVenta > 0 ? { precioFinal: valorVenta } : {}),
...(enganche > 0 ? { enganche } : {}),
...(formaPagoLabel(vc.tiposCredito) !== undefined ? { formaPagoLabel: formaPagoLabel(vc.tiposCredito) } : {}),
})
return existente
async function ligarApartadoACliente(clienteId: string, apartadoId: string): Promise<void> {
try {
await prisma.cliente.update({ where: { id: clienteId }, data: { mondayApartadoId: apartadoId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo ligar el apartado ${apartadoId} al cliente ${clienteId}:`, err)
}
}
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
/** POST /api/clientes — persiste el alta en SQLite y liga el apartado del funnel */
export async function crearCliente(req: Request, res: Response): Promise<void> {
const body = crearClienteSchema.parse(req.body)
const dg = body.datosGenerales
@@ -366,62 +230,22 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
},
})
await moverLeadAApartoSiAplica(body as Record<string, unknown>)
// Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null
let mondayContactoId: string | null = null
let mondayApartadoId: string | null = null
const datosContacto: DatosContacto = {
nombre: dg.nombreCompleto,
estadoLabel: 'CLIENTE',
...(dg.celular !== undefined ? { telefono: dg.celular } : {}),
...(dg.email !== undefined ? { email: dg.email } : {}),
curp: dg.curp,
rfc: dg.rfc,
...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}),
// Perfil demográfico: solo lo que el formato de alta captura.
// Sin fuente en el formulario: Sexo (no se pregunta) y Actividad
// Economica distinta de 'Empleado' (INDEPENDIENTE es ambiguo).
...perfilDemografico(dg),
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
}
try {
mondayContactoId = await mondayService.crearContacto(datosContacto)
mondayApartadoId = await apartadoDeAlta(dg, vc, req.usuarioToken?.sub ?? null, body.apartadoMondayId)
await prisma.cliente.update({
where: { id: cliente.id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo replicar el cliente en Monday:', err)
// Encola la réplica diferida de lo que faltó y conserva los ids que sí se crearon
mondayApartadoId = await encolarReplicasPendientesCliente(
cliente.id,
datosContacto,
mondayContactoId,
mondayApartadoId,
dg,
vc,
req.usuarioToken?.sub ?? null,
body.apartadoMondayId,
)
if (mondayContactoId !== null || mondayApartadoId !== null) {
try {
await prisma.cliente.update({
where: { id: cliente.id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err2) {
// eslint-disable-next-line no-console
console.error('No se pudieron guardar los ids Monday del cliente:', err2)
}
// Apartado del alta: el existente (botón "Alta Enkontrol" desde Mis
// apartados) se LIGA tal cual; si el alta viene del funnel se mueve la
// oportunidad a 'Aparto' y se crea/enriquece el apartado local.
let mondayApartadoId = apartadoLigadoDeBody(body as Record<string, unknown>)
if (mondayApartadoId === null) {
const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record<string, unknown>, req.usuarioToken?.sub ?? null)
if (apartadoFunnelId !== null) {
mondayApartadoId = apartadoFunnelId
await enriquecerApartadoLocal(apartadoFunnelId, dg, vc)
}
}
if (mondayApartadoId !== null) {
await ligarApartadoACliente(cliente.id, mondayApartadoId)
}
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
res.status(201).json({ ...cliente, mondayContactoId: null, mondayApartadoId })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_CLIENTE', 'cliente', cliente.id)
}
@@ -429,7 +253,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
/**
* POST /progreso/iniciar — solo exige el nombre; curp/rfc/email/celular son
* opcionales porque el Contacto PROSPECTO se crea en cuanto hay nombre.
* opcionales porque se completan en los pasos siguientes.
*/
export const iniciarProgresoSchema = z
.object({
@@ -455,62 +279,7 @@ export const iniciarProgresoSchema = z
/** PATCH /progreso/:id — passthrough libre; solo se valida que sea un objeto */
const actualizarProgresoSchema = z.object({}).passthrough()
const seccionDe = (datos: Record<string, unknown>, clave: string): Record<string, unknown> => {
const v = datos[clave]
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
}
const textoDe = (v: unknown): string | undefined =>
typeof v === 'string' && v.trim() !== '' ? v : undefined
/** Texto de la columna Dirección del Contacto a partir del domicilio capturado */
export function textoDireccion(dom: Record<string, unknown>): string | undefined {
const t = (k: string): string => textoDe(dom[k])?.trim() ?? ''
const calleNum = [t('calle'), t('numExt')].filter((p) => p !== '').join(' ')
const numInt = t('numInt')
const linea1 = [calleNum, numInt !== '' ? `Int. ${numInt}` : ''].filter((p) => p !== '').join(', ')
const cp = t('cp')
const partes = [linea1, t('colonia'), cp !== '' ? `CP ${cp}` : '', t('municipio'), t('estado')].filter(
(p) => p !== '',
)
return partes.length > 0 ? partes.join(', ') : undefined
}
/**
* Deriva TODOS los campos mapeables del Contacto de Monday presentes en el
* estado del wizard (idempotente: se manda el estado completo en cada avance).
*/
export function contactoDesdeDatos(datos: Record<string, unknown>): Omit<DatosContacto, 'estadoLabel'> {
const dg = seccionDe(datos, 'datosGenerales')
const empleo = seccionDe(datos, 'empleo')
const vc = seccionDe(datos, 'viviendaCredito')
// El domicilio puede venir suelto ('domicilio') o como domicilioFiscal del paso Vivienda
const domRaiz = seccionDe(datos, 'domicilio')
const dom = Object.keys(domRaiz).length > 0 ? domRaiz : seccionDe(vc, 'domicilioFiscal')
const curp = textoDe(dg['curp'])
const rfc = textoDe(dg['rfc'])
const email = textoDe(dg['email'])
const telefono = textoDe(dg['celular'])
const direccion = textoDireccion(dom) ?? textoDe(dg['direccion'])
const actividadEconomica = actividadEconomicaLabel(textoDe(empleo['tipoEmpleo']))
return {
nombre: textoDe(dg['nombreCompleto']) ?? '',
...(curp !== undefined ? { curp } : {}),
...(rfc !== undefined ? { rfc } : {}),
...(email !== undefined ? { email } : {}),
...(telefono !== undefined ? { telefono } : {}),
...(direccion !== undefined ? { direccion } : {}),
...perfilDemografico({
estadoCivil: textoDe(dg['estadoCivil']),
tipoVivienda: textoDe(dg['tipoVivienda']),
tipoCompra: textoDe(dg['tipoCompra']),
fechaNacimiento: textoDe(dg['fechaNacimiento']),
}),
...(actividadEconomica !== undefined ? { actividadEconomicaLabel: actividadEconomica } : {}),
}
}
/** POST /api/clientes/progreso/iniciar — crea el alta en progreso + Contacto PROSPECTO */
/** POST /api/clientes/progreso/iniciar — crea el alta en progreso */
export async function iniciarProgreso(req: Request, res: Response): Promise<void> {
const body = iniciarProgresoSchema.parse(req.body)
const dg = body.datosGenerales
@@ -528,26 +297,10 @@ export async function iniciarProgreso(req: Request, res: Response): Promise<void
},
})
// Contacto PROSPECTO en Monday; si falla, el progreso local ya quedó y la
// réplica queda encolada (CREAR_CONTACTO) para cuando Monday vuelva
let mondayContactoId: string | null = null
const datosContactoProspecto: DatosContacto = {
...contactoDesdeDatos(body as Record<string, unknown>),
estadoLabel: 'PROSPECTO',
}
try {
mondayContactoId = await mondayService.crearContacto(datosContactoProspecto)
await prisma.cliente.update({ where: { id: cliente.id }, data: { mondayContactoId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo crear el Contacto PROSPECTO en Monday:', err)
await encolarSyncSeguro('CREAR_CONTACTO', { datos: datosContactoProspecto }, cliente.id)
}
res.status(201).json({ id: cliente.id, mondayContactoId })
res.status(201).json({ id: cliente.id, mondayContactoId: null })
}
/** PATCH /api/clientes/progreso/:id — guarda el avance y lo replica en el Contacto */
/** PATCH /api/clientes/progreso/:id — guarda el avance del wizard (local) */
export async function actualizarProgreso(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
const body = actualizarProgresoSchema.parse(req.body)
@@ -556,21 +309,7 @@ export async function actualizarProgreso(req: Request, res: Response): Promise<v
await prisma.cliente.update({ where: { id }, data: { payloadJson: JSON.stringify(body) } })
// Réplica idempotente en Monday; un fallo no rompe el avance del wizard
// (queda encolada como ACTUALIZAR_CONTACTO para reintentar al volver Monday)
let mondaySync = false
if (cliente.mondayContactoId !== null) {
try {
await mondayService.actualizarContacto(cliente.mondayContactoId, contactoDesdeDatos(body))
mondaySync = true
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo actualizar el Contacto ${cliente.mondayContactoId} en Monday:`, err)
await encolarSyncSeguro('ACTUALIZAR_CONTACTO', { datos: contactoDesdeDatos(body) }, id)
}
}
res.json({ id, mondaySync })
res.json({ id, mondaySync: false })
}
/**
@@ -600,66 +339,20 @@ export async function completarProgreso(req: Request, res: Response): Promise<vo
},
})
await moverLeadAApartoSiAplica(body as Record<string, unknown>)
// El Contacto (creado como PROSPECTO al iniciar) pasa a CLIENTE con los
// datos finales; si no existe (nunca hubo Monday), se crea como en el alta directa
let mondayContactoId: string | null = cliente.mondayContactoId
let mondayApartadoId: string | null = cliente.mondayApartadoId
const datosContactoCliente: DatosContacto = {
...contactoDesdeDatos(body as Record<string, unknown>),
estadoLabel: 'CLIENTE',
}
try {
if (mondayContactoId !== null) {
await mondayService.actualizarContacto(mondayContactoId, datosContactoCliente)
} else {
mondayContactoId = await mondayService.crearContacto(datosContactoCliente)
}
if (mondayApartadoId === null) {
// Con apartadoMondayId (alta desde un apartado existente) se liga y
// enriquece ese item; si no, se crea. El capturista original del alta
// progresiva es quien queda como Vendedor.
mondayApartadoId = await apartadoDeAlta(dg, vc, cliente.capturadoPorId, body.apartadoMondayId)
}
await prisma.cliente.update({
where: { id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo replicar el cliente en Monday:', err)
// Encola la réplica diferida: actualización del Contacto si ya existía
// (es idempotente) o su creación; y el Apartado si no se creó
if (mondayContactoId !== null) {
await encolarSyncSeguro('ACTUALIZAR_CONTACTO', { datos: datosContactoCliente }, id)
}
mondayApartadoId = await encolarReplicasPendientesCliente(
id,
datosContactoCliente,
mondayContactoId,
mondayApartadoId,
dg,
vc,
cliente.capturadoPorId,
body.apartadoMondayId,
)
if (mondayContactoId !== null || mondayApartadoId !== null) {
try {
await prisma.cliente.update({
where: { id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err2) {
// eslint-disable-next-line no-console
console.error('No se pudieron guardar los ids Monday del cliente:', err2)
}
// Apartado del alta (misma lógica que el alta directa)
let mondayApartadoId = apartadoLigadoDeBody(body as Record<string, unknown>)
if (mondayApartadoId === null) {
const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record<string, unknown>, cliente.capturadoPorId)
if (apartadoFunnelId !== null) {
mondayApartadoId = apartadoFunnelId
await enriquecerApartadoLocal(apartadoFunnelId, dg, vc)
}
}
if (mondayApartadoId !== null) {
await ligarApartadoACliente(id, mondayApartadoId)
}
res.status(201).json({ ...actualizado, mondayContactoId, mondayApartadoId })
res.status(201).json({ ...actualizado, mondayContactoId: null, mondayApartadoId })
}
/** GET /api/clientes/:id */
@@ -729,29 +422,6 @@ export async function subirDocumentos(req: Request, res: Response): Promise<void
}),
),
)
// Prende el checkbox del documento en el item de Apartado del cliente
// (pipeline Enkontrol). Un fallo de Monday no rompe la subida: queda
// encolado (CHECKBOX_DOC) para que el worker lo aplique al sincronizar.
const checkbox = checkboxParaTipoDocumento(tipo)
if (checkbox !== null && cliente.mondayApartadoId !== null) {
if (esIdLocal(cliente.mondayApartadoId)) {
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId: cliente.mondayApartadoId }, cliente.id)
} else {
try {
await mondayService.cambiarValorColumna(
config.monday.boards.apartado,
cliente.mondayApartadoId,
checkbox,
true,
)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${cliente.mondayApartadoId}:`, err)
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId: cliente.mondayApartadoId }, cliente.id)
}
}
}
res.status(201).json(documentos)
}
@@ -769,81 +439,18 @@ export async function listarDocumentos(req: Request, res: Response): Promise<voi
/**
* GET /api/altas/prefill-apartado/:itemId — todo lo ya recolectado de un
* apartado de Monday para prellenar el alta de cliente (botón "Alta Enkontrol"
* de Mis apartados): ficha del apartado, contacto relacionado
* (perfil completo), oportunidad relacionada (perfil de la afluencia) y la
* cotización más reciente vinculada. Las relaciones se arman por teléfono
* (últimos 10 dígitos) o email.
* apartado para prellenar el alta de cliente (botón "Alta Enkontrol" de Mis
* apartados): ficha del apartado (fila LeadCaptura board=APARTADO), contacto
* relacionado (ficha del Cliente), oportunidad relacionada (perfil de la
* afluencia) y la cotización más reciente vinculada. Las relaciones se arman
* por teléfono (últimos 10 dígitos) o email. 404 si no existe el apartado.
*/
export async function prefillApartadoEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
// Monday caído (circuit breaker): prefill 100% local
if (!saludMonday.estaDisponible()) {
res.json(await prefillApartadoLocal(itemId))
return
}
try {
const apartado = (await mondayService.getApartados()).find((a) => a.id === itemId)
if (!apartado) throw new NotFoundError('Apartado no encontrado')
const tel = (apartado.telefono ?? '').replace(/\D/g, '').slice(-10)
const email = (apartado.email ?? '').trim().toLowerCase()
const coincide = (t: string, e: string): boolean =>
(tel !== '' && t.replace(/\D/g, '').length >= 10 && t.replace(/\D/g, '').slice(-10) === tel) ||
(email !== '' && e.trim().toLowerCase() === email)
const [contactos, oportunidades] = await Promise.all([
mondayService.getContactos(),
mondayService.getOportunidades(),
])
const contactoBase = contactos.find((c) => coincide(c.telefono, c.email))
const contacto = contactoBase ? await mondayService.getContactoDetalle(contactoBase.id) : null
const oportunidad = oportunidades.find((o) => coincide(o.telefono, o.email)) ?? null
// Cotización local más reciente: vinculada a la oportunidad o por teléfono
let cotizacion = null
if (oportunidad) {
cotizacion = await prisma.cotizacion.findFirst({
where: { mondayItemId: oportunidad.id },
orderBy: { createdAt: 'desc' },
})
}
if (!cotizacion && tel !== '') {
cotizacion = await prisma.cotizacion.findFirst({
where: { clienteTelefono: { contains: tel } },
orderBy: { createdAt: 'desc' },
})
}
res.json({
apartado,
contacto,
oportunidad,
cotizacion: cotizacion
? {
tipoPropuesta: cotizacion.tipoPropuesta,
acabados: cotizacion.acabados,
apartado: cotizacion.apartado,
precioLista: cotizacion.precioLista,
precioFinal: cotizacion.precioFinal,
enganche: cotizacion.enganche,
numMensualidades: cotizacion.numMensualidades,
mensualidad: cotizacion.mensualidad,
pagoFinal: cotizacion.pagoFinal,
}
: null,
})
} catch (err) {
// Monday se cayó a media lectura: prefill local (el 404 de "apartado no
// encontrado" NO es fallo de Monday y se propaga intacto)
if (!esFalloMonday(err)) throw err
res.json(await prefillApartadoLocal(itemId))
}
res.json(await prefillApartadoLocal(itemId))
}
/** Shape de la cotización en la respuesta del prefill (compartido Monday/local) */
/** Shape de la cotización en la respuesta del prefill */
function cotizacionPrefill(cotizacion: {
tipoPropuesta: string
acabados: boolean
@@ -869,11 +476,10 @@ function cotizacionPrefill(cotizacion: {
}
/**
* Prefill local (Monday caído): el apartado es la fila LeadCaptura board=
* APARTADO (id loc_* o sombra); el contacto sale del Cliente ligado, la
* oportunidad de la captura board=OPORTUNIDADES con el mismo teléfono (su
* perfil va en datosJson) y la cotización de la tabla local. Mismo contrato
* que el camino Monday. 404 si no existe el apartado local.
* Prefill local: el apartado es la fila LeadCaptura board=APARTADO (id loc_* o
* sombra); el contacto sale del Cliente ligado, la oportunidad de la captura
* board=OPORTUNIDADES con el mismo teléfono (su perfil va en datosJson) y la
* cotización de la tabla local.
*/
async function prefillApartadoLocal(itemId: string) {
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })

View File

@@ -1,138 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError, NotFoundError } from '../utils/errors.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
leadCaptura: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
cotizacion: {
findMany: vi.fn(),
},
documento: {
findMany: vi.fn(),
},
usuario: {
findMany: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
getContactos: vi.fn(),
getContactoDetalle: vi.fn(),
getUsuariosWorkspace: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { detalleContactoEndpoint, listarContactosEndpoint } from './contactos.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const capturaLocal = {
id: 'row-1',
leadId: 'loc_1',
nombreLead: 'ANA DEMO',
telefono: '6649998877',
email: null,
origen: 'WhatsApp',
desarrollo: 'SENDA',
modelo: '1R',
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
motivo: null,
submotivo: null,
datosJson: null,
mondayItemId: null,
pendienteSync: true,
usuarioId: 'u-1',
createdAt: new Date('2026-09-02T10:00:00'),
updatedAt: new Date('2026-09-03T10:00:00'),
}
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findMany.mockResolvedValue([capturaLocal])
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.documento.findMany.mockResolvedValue([])
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }])
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
})
describe('listarContactosEndpoint (fallback local)', () => {
it('Monday caído (circuit breaker): lista local sin llamar a Monday', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const { res, json } = resFake()
await listarContactosEndpoint({} as Request, res)
expect(mondayMock.getContactos).not.toHaveBeenCalled()
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' })
})
it('Monday lanza 502: fallback local', async () => {
mondayMock.getContactos.mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await listarContactosEndpoint({} as Request, res)
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista[0]).toMatchObject({ id: 'loc_1', fuente: 'local' })
})
})
describe('detalleContactoEndpoint (fallback local)', () => {
it('Monday caído: detalle 100% local con la misma forma', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal)
const { res, json } = resFake()
await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res)
expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled()
const detalle = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877' })
expect(detalle['propiedad']).toBeNull()
expect((detalle['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1)
expect(Array.isArray(detalle['historial'])).toBe(true)
})
it('Monday caído y el id no existe en local → 404', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
await expect(
detalleContactoEndpoint({ params: { id: 'nope' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
})
it('Monday lanza 502: cae al detalle local; el 404 de Monday se propaga intacto', async () => {
mondayMock.getContactoDetalle.mockRejectedValue(new AppError(502, 'Monday no respondió'))
prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal)
const { res, json } = resFake()
await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res)
expect(json).toHaveBeenCalledOnce()
mondayMock.getContactoDetalle.mockRejectedValue(new NotFoundError('Contacto no encontrado'))
await expect(
detalleContactoEndpoint({ params: { id: 'x' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
})
})

View File

@@ -1,23 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { listarContactosEndpoint } from './contactos.controller.js'
// ---------- Monday mockeado (los tests no tocan la red) ----------
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const mondayMock = vi.hoisted(() => ({ getContactos: vi.fn() }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
const CONTACTOS = [
{
id: '1',
nombre: 'JUAN PÉREZ',
telefono: '5512345678',
email: 'juan@correo.mx',
estado: 'CLIENTE',
updatedAt: '2026-09-01T10:00:00Z',
const prismaMock = vi.hoisted(() => ({
cliente: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
]
leadCaptura: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
cotizacion: {
findMany: vi.fn(),
},
documento: {
findMany: vi.fn(),
},
usuario: {
findMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { detalleContactoEndpoint, listarContactosEndpoint } from './contactos.controller.js'
/**
* Contactos 100% locales: la lista y el detalle se arman desde las tablas
* locales (Cliente + LeadCaptura + Documento + Cotizacion) con la misma forma
* de DTO que consumen los fronts.
*/
const capturaLocal = {
id: 'row-1',
leadId: 'loc_1',
nombreLead: 'ANA DEMO',
telefono: '6649998877',
email: null,
origen: 'WhatsApp',
desarrollo: 'SENDA',
modelo: '1R',
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
motivo: null,
submotivo: null,
datosJson: null,
mondayItemId: null,
pendienteSync: true,
usuarioId: 'u-1',
createdAt: new Date('2026-09-02T10:00:00'),
updatedAt: new Date('2026-09-03T10:00:00'),
}
function resFake() {
const json = vi.fn()
@@ -27,21 +62,56 @@ function resFake() {
beforeEach(() => {
vi.clearAllMocks()
mondayMock.getContactos.mockResolvedValue(CONTACTOS)
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findMany.mockResolvedValue([capturaLocal])
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.documento.findMany.mockResolvedValue([])
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }])
})
describe('listarContactosEndpoint (GET /api/contactos)', () => {
it('devuelve los contactos del tablero de Monday', async () => {
describe('listarContactosEndpoint (GET /api/contactos) — local', () => {
it('lista desde LeadCaptura/Cliente con la misma forma (fuente local)', async () => {
const { res, json } = resFake()
await listarContactosEndpoint({} as Request, res)
await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' } } as unknown as Request, res)
expect(json).toHaveBeenCalledWith(CONTACTOS)
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista[0]).toMatchObject({ id: 'loc_1', nombre: 'ANA DEMO', estado: 'Afluencia', fuente: 'local' })
})
it('propaga el error si Monday falla (el error handler responde 5xx)', async () => {
mondayMock.getContactos.mockRejectedValue(new Error('Monday caído'))
it('filtra por el usuario del token (sus clientes y sus capturas)', async () => {
const { res } = resFake()
await expect(listarContactosEndpoint({} as Request, res)).rejects.toThrow('Monday caído')
await listarContactosEndpoint({ usuarioToken: { sub: 'u-1' } } as unknown as Request, res)
expect(prismaMock.cliente.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { capturadoPorId: 'u-1' } }),
)
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ usuarioId: 'u-1' }) }),
)
})
})
describe('detalleContactoEndpoint (GET /api/contactos/:id/detalle) — local', () => {
it('detalle 100% local con la misma forma', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(capturaLocal)
const { res, json } = resFake()
await detalleContactoEndpoint({ params: { id: 'loc_1' } } as unknown as Request, res)
const detalle = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877' })
expect(detalle['propiedad']).toBeNull()
expect((detalle['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1)
expect(Array.isArray(detalle['historial'])).toBe(true)
})
it('el id no existe en local → 404', async () => {
await expect(
detalleContactoEndpoint({ params: { id: 'nope' } } as unknown as Request, resFake().res),
).rejects.toMatchObject({ status: 404 })
})
})

View File

@@ -1,128 +1,28 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { detalleContacto, claveTelefono } from '../services/contactos-detalle.service.js'
import { detalleContactoLocal, listarContactosLocal, exportarCSV } from '../services/contactos-local.service.js'
import { perfilCompleto } from '../services/contactos-detalle.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { prisma } from '../services/prisma.js'
import { NotFoundError } from '../utils/errors.js'
/**
* GET /api/contactos (Bearer) → contactos del tablero Contactos de Monday
* (nombre, teléfono, email, estado), lo más reciente primero, máx 100.
* Monday caído (o 502) → lista local (Cliente + LeadCaptura), mismo shape.
* GET /api/contactos (Bearer) → contactos locales (tabla Cliente + LeadCaptura:
* nombre, teléfono, email, estado), lo más reciente primero, máx 100. Filtra
* por usuario actual: clientes capturados por él + leads asignados a él.
*/
export async function listarContactosEndpoint(req: Request, res: Response): Promise<void> {
const usuarioId = req.usuarioToken?.sub
if (!saludMonday.estaDisponible()) {
res.json(await listarContactosLocal(usuarioId))
return
}
try {
res.json(await mondayService.getContactos())
} catch (err) {
if (!esFalloMonday(err)) throw err
res.json(await listarContactosLocal(usuarioId))
}
res.json(await listarContactosLocal(req.usuarioToken?.sub))
}
/**
* GET /api/contactos/:id/detalle (Bearer) → ficha completa del contacto:
* todas sus columnas, la propiedad comprada/en proceso (apartado relacionado),
* quién atiende cada item relacionado (leads, oportunidades, apartados) y el
* historial completo de atención (activity log de Monday + capturas locales).
* GET /api/contactos/:id/detalle (Bearer) → ficha completa del contacto armada
* 100% local: sus datos, la propiedad comprada/en proceso (apartado
* relacionado), quién atiende cada item relacionado (leads, oportunidades,
* apartados) y el historial de atención (capturas, altas, documentos y
* cotizaciones locales).
*/
export async function detalleContactoEndpoint(req: Request, res: Response): Promise<void> {
const contactoId = String(req.params['id'])
// Monday caído (circuit breaker): detalle 100% local
if (!saludMonday.estaDisponible()) {
const detalle = await detalleContactoLocal(contactoId)
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
res.json(detalle)
return
}
try {
const contacto = await mondayService.getContactoDetalle(contactoId)
if (contacto === null) throw new NotFoundError('Contacto no encontrado')
// Nombres para "atendidoPor"/historial: usuarios del ecosistema + workspace
const mapeados = await prisma.usuario.findMany({
where: { mondayUserId: { not: null }, activo: true },
select: { nombre: true, mondayUserId: true },
})
const nombresPorMondayId = new Map<number, string>(
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
)
try {
for (const u of await mondayService.getUsuariosWorkspace()) {
if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre)
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
}
// Registro local: capturas con el mismo teléfono (Monday no guarda quién
// capturó) y la ficha de cliente ligada a este contacto (o por celular).
// LeadCaptura/Cliente no tienen relación Prisma a Usuario: se resuelve a mano.
const clave = claveTelefono(contacto['Teléfono'] ?? '')
const capturasTodas = await prisma.leadCaptura.findMany({
where: { telefono: { not: null } },
orderBy: { createdAt: 'desc' },
})
const capturasCrudas = capturasTodas.filter((c) => c.telefono !== null && clave !== '' && claveTelefono(c.telefono) === clave)
const clienteCrudo = await prisma.cliente.findFirst({
where: {
OR: [
{ mondayContactoId: contactoId },
...(clave !== '' ? [{ celular: { contains: clave } }] : []),
],
},
include: { _count: { select: { documentos: true } } },
orderBy: { createdAt: 'desc' },
})
// Nombres de capturistas (ids locales → nombre para mostrar)
const idsUsuarios = [
...new Set([
...capturasCrudas.map((c) => c.usuarioId),
...(clienteCrudo?.capturadoPorId ? [clienteCrudo.capturadoPorId] : []),
]),
]
const usuariosLocal = idsUsuarios.length
? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } })
: []
const nombreLocal = new Map(usuariosLocal.map((u) => [u.id, u.nombre]))
const capturas = capturasCrudas.map((c) => ({
leadId: c.leadId,
nombreLead: c.nombreLead,
createdAt: c.createdAt,
usuario: nombreLocal.has(c.usuarioId) ? { nombre: nombreLocal.get(c.usuarioId)! } : null,
}))
const clienteLocal = clienteCrudo
? {
...clienteCrudo,
capturadoPor:
clienteCrudo.capturadoPorId && nombreLocal.has(clienteCrudo.capturadoPorId)
? { nombre: nombreLocal.get(clienteCrudo.capturadoPorId)! }
: null,
}
: null
res.json(await detalleContacto(mondayService, contactoId, nombresPorMondayId, capturas, clienteLocal))
} catch (err) {
// Monday se cayó a media lectura: detalle local (el 404 de "no existe en
// Monday" NO es fallo de Monday y se propaga intacto)
if (!esFalloMonday(err)) throw err
const detalle = await detalleContactoLocal(contactoId)
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
res.json(detalle)
}
const detalle = await detalleContactoLocal(String(req.params['id']))
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
res.json(detalle)
}
/**

View File

@@ -1,118 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError, NotFoundError } from '../utils/errors.js'
// ---------- Prisma, Monday y Funnel mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
findUnique: vi.fn(),
},
cotizacion: {
create: vi.fn(),
update: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
getUnidad: vi.fn(),
crearOportunidad: vi.fn(),
actualizarOportunidadCotizacion: vi.fn(),
}))
const funnelMock = vi.hoisted(() => ({
oportunidadPorTelefono: 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 }))
vi.mock('../services/auditoria.service.js', () => ({
registrar: vi.fn(),
buscar: vi.fn(),
}))
import { crearCotizacion } from './cotizaciones.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
/**
* Fallback del getUnidad obligatorio al guardar cotización: con Monday caído
* (o 502) la unidad sale del espejo local; si no existe en ninguno → 404.
*/
const filaUnidad = {
codigo: 'U-101',
desarrollo: 'DIAGONAL',
modelo: '2RA',
supConstruccion: 60,
supExcedente: 0,
supEstacionamiento: 0,
precioLista: 1_000_000,
precioAcabados: 1_100_000,
status: 'DISPONIBLE',
etapa: '',
}
const bodyBase = {
unidadCodigo: 'U-101',
acabados: false,
clienteNombre: 'Cliente Demo',
clienteTelefono: '4771234567',
tipoPropuesta: 'BANCARIO' as const,
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad)
prismaMock.cotizacion.create.mockImplementation(async ({ data }) => ({ id: 'cot-1', ...data }))
prismaMock.cotizacion.update.mockResolvedValue({})
prismaMock.syncPendiente.create.mockResolvedValue({})
mondayMock.crearOportunidad.mockResolvedValue('monday-op-1')
mondayMock.actualizarOportunidadCotizacion.mockResolvedValue(undefined)
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
})
describe('crearCotizacion (getUnidad con fallback local)', () => {
it('Monday caído: cotiza con la unidad del espejo local (sin llamar a getUnidad)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const { res, status, json } = resFake()
await crearCotizacion({ body: bodyBase } as Request, res)
expect(mondayMock.getUnidad).not.toHaveBeenCalled()
expect(prismaMock.unidad.findUnique).toHaveBeenCalledWith({ where: { codigo: 'U-101' } })
expect(status).toHaveBeenCalledWith(201)
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL' })
expect(respuesta['propuesta']).toBeDefined()
})
it('Monday lanza 502 en getUnidad: mismo fallback local', async () => {
mondayMock.getUnidad.mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, status } = resFake()
await crearCotizacion({ body: bodyBase } as Request, res)
expect(status).toHaveBeenCalledWith(201)
})
it('la unidad no existe ni en Monday ni en local → 404 como hoy', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.unidad.findUnique.mockResolvedValue(null)
const { res } = resFake()
await expect(crearCotizacion({ body: bodyBase } as Request, res)).rejects.toThrow(NotFoundError)
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
})

View File

@@ -1,48 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
// ---------- Prisma, Monday y Funnel mockeados (los tests no tocan BD ni red) ----------
// ---------- Prisma mockeado (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
findUnique: vi.fn(),
},
leadCaptura: {
findFirst: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
leadOportunidad: {
findUnique: vi.fn(),
},
cotizacion: {
create: vi.fn(),
update: vi.fn(),
findMany: vi.fn(),
},
syncPendiente: {
actividadLocal: {
create: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
}))
const mondayMock = vi.hoisted(() => ({
getUnidad: vi.fn(),
crearOportunidad: vi.fn(),
actualizarOportunidadCotizacion: vi.fn(),
}))
const funnelMock = vi.hoisted(() => ({
oportunidadPorTelefono: vi.fn(),
$transaction: 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 }))
import { crearCotizacion } from './cotizaciones.controller.js'
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
const unidadFake = {
/**
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
* cotización se persiste en SQLite y se liga a la oportunidad local (que pasa
* a etapa 'Cotizador').
*/
const filaUnidad = {
codigo: 'U-101',
modelo: 'M1',
fechaEntrega: null,
supEstacionamiento: 0,
desarrollo: 'DIAGONAL',
modelo: '2RA',
supConstruccion: 60,
supExcedente: 0,
precioSinAcabados: 1_000_000,
precioConAcabados: 1_100_000,
supEstacionamiento: 0,
precioLista: 1_000_000,
precioAcabados: 1_100_000,
status: 'DISPONIBLE',
desarrollo: 'DIAGONAL',
etapa: '',
}
const bodyBase = {
@@ -54,7 +65,7 @@ const bodyBase = {
}
function reqFake(body: unknown): Request {
return { body } as Request
return { body, usuarioToken: { sub: 'u-1' } } as unknown as Request
}
function resFake() {
@@ -65,111 +76,164 @@ function resFake() {
beforeEach(() => {
vi.clearAllMocks()
mondayMock.getUnidad.mockResolvedValue(unidadFake)
mondayMock.crearOportunidad.mockResolvedValue('op-nueva')
mondayMock.actualizarOportunidadCotizacion.mockResolvedValue(undefined)
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad)
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
prismaMock.cotizacion.create.mockImplementation(async ({ data }) => ({ id: 'cot-1', ...data }))
prismaMock.cotizacion.update.mockResolvedValue({})
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.actividadLocal.create.mockImplementation(async ({ data }) => ({ id: 'act-1', ...data }))
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', () => {
it('sin oportunidad mapeada para el teléfono: crea una nueva (comportamiento actual)', async () => {
describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
it('cotiza con la unidad del espejo local (tabla Unidad)', async () => {
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(prismaMock.unidad.findUnique).toHaveBeenCalledWith({ where: { codigo: 'U-101' } })
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce()
expect(mondayMock.actualizarOportunidadCotizacion).not.toHaveBeenCalled()
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'op-nueva' })
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null })
expect(respuesta['propuesta']).toBeDefined()
expect(respuesta['folio']).toBeDefined()
})
it('con teléfono de lead mapeado: ACTUALIZA esa oportunidad (no duplica) y la pasa a Cotizador', async () => {
funnelMock.oportunidadPorTelefono.mockResolvedValue('op-del-lead')
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(funnelMock.oportunidadPorTelefono).toHaveBeenCalledWith('4771234567')
expect(mondayMock.crearOportunidad).not.toHaveBeenCalled()
expect(mondayMock.actualizarOportunidadCotizacion).toHaveBeenCalledOnce()
const [opId, datos] = mondayMock.actualizarOportunidadCotizacion.mock.calls[0] as [string, Record<string, unknown>]
expect(opId).toBe('op-del-lead')
expect(datos).toMatchObject({ unidadCodigo: 'U-101', tipoPropuestaLabel: 'CREDITO BANCARIO', estadoLabel: 'Cotizador' })
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'op-del-lead' })
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
where: { id: 'cot-1' },
data: { mondayItemId: 'op-del-lead' },
})
})
it('sin teléfono en el body no consulta el funnel', async () => {
it('la unidad no existe en el espejo → 404 sin crear cotización', async () => {
prismaMock.unidad.findUnique.mockResolvedValue(null)
const { res } = resFake()
const { clienteTelefono: _omitido, ...sinTelefono } = bodyBase
await crearCotizacion(reqFake(sinTelefono), res)
expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled()
expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce()
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({ status: 404 })
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
it('si Monday falla, la cotización igual se crea (201 con mondayItemId null) y la vinculación queda encolada', async () => {
mondayMock.crearOportunidad.mockRejectedValue(new Error('Monday caído'))
vi.spyOn(console, 'error').mockImplementation(() => {})
prismaMock.syncPendiente.create.mockResolvedValue({})
it('sin oportunidad mapeada para el teléfono: mondayItemId null (no crea oportunidades)', async () => {
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: null })
expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce()
// COTIZACION_OPORTUNIDAD con los datos completos + teléfono para el match del worker
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'COTIZACION_OPORTUNIDAD',
referenciaLocal: 'cot-1',
}),
})
const llamada = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string } }
expect(JSON.parse(llamada.data.payloadJson)).toMatchObject({
clienteTelefono: '4771234567',
datos: { unidadCodigo: 'U-101', estadoLabel: 'Cotizador' },
})
})
it('si falla la actualización con oportunidadId explícito, se encola con ESE id', async () => {
mondayMock.actualizarOportunidadCotizacion.mockRejectedValue(new Error('Monday caído'))
vi.spyOn(console, 'error').mockImplementation(() => {})
prismaMock.syncPendiente.create.mockResolvedValue({})
const { res, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
expect(status).toHaveBeenCalledWith(201)
const llamada = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string } }
expect(JSON.parse(llamada.data.payloadJson)).toMatchObject({ oportunidadId: 'opp-midia-1' })
})
it('con oportunidadId explícito (botón "Cotizar" de Mi día): actualiza ESE item y no consulta el funnel', async () => {
it('con teléfono de una afluencia local: LIGA esa oportunidad y la pasa a Cotizador', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue({
leadId: 'loc_op_1',
board: 'OPORTUNIDADES',
telefono: '4771234567',
})
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op_1', board: 'OPORTUNIDADES' })
const { res, status, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled()
expect(mondayMock.crearOportunidad).not.toHaveBeenCalled()
expect(mondayMock.actualizarOportunidadCotizacion).toHaveBeenCalledOnce()
const [opId, datos] = mondayMock.actualizarOportunidadCotizacion.mock.calls[0] as [string, Record<string, unknown>]
expect(opId).toBe('opp-midia-1')
expect(datos).toMatchObject({ estadoLabel: 'Cotizador' })
// La cotización queda vinculada al item del cliente
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'loc_op_1' })
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
where: { id: 'cot-1' },
data: { mondayItemId: 'loc_op_1' },
})
// La oportunidad pasa a etapa 'Cotizador' localmente
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_op_1' },
data: { etapa: 'Cotizador', pendienteSync: true },
})
})
it('con teléfono de un lead histórico: usa la oportunidad del mapping lead→oportunidad', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue({
leadId: 'loc_lead_1',
board: 'LEADS',
telefono: '4771234567',
})
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_mapping' })
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op_mapping', board: 'OPORTUNIDADES' })
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'loc_op_mapping' })
})
it('con oportunidadId explícito (botón "Cotizar" de Mi día): liga ESE id sin buscar por teléfono', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'opp-midia-1', board: 'OPORTUNIDADES' })
const { res, status, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'opp-midia-1' })
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
where: { id: 'cot-1' },
data: { mondayItemId: 'opp-midia-1' },
})
})
})
describe('LABEL_PROPUESTA_MONDAY (códigos del cotizador → labels reales del board)', () => {
it('los 5 códigos del enum mapean a los labels sembrados en la columna "Tipo propuesta"', async () => {
const { LABEL_PROPUESTA_MONDAY } = await import('./cotizaciones.controller.js')
// Labels reales verificados en Monday (difieren en guiones/espacios)
expect(LABEL_PROPUESTA_MONDAY['BANCARIO']).toBe('CREDITO BANCARIO')
expect(LABEL_PROPUESTA_MONDAY['INFONAVIT']).toBe('INFONAVIT-COFINAVIT')
expect(LABEL_PROPUESTA_MONDAY['COFINAVIT']).toBe('INFONAVIT-COFINAVIT')
expect(LABEL_PROPUESTA_MONDAY['CONTADO']).toBe('CONTADO')
expect(LABEL_PROPUESTA_MONDAY['SEMI_CONTADO']).toBe('SEMI CONTADO')
it('sin teléfono en el body no busca oportunidades', async () => {
const { res } = resFake()
const { clienteTelefono: _omitido, ...sinTelefono } = bodyBase
await crearCotizacion(reqFake(sinTelefono), res)
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
})
})
describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario) — actividades locales', () => {
it('crea una ActividadLocal al vendedor de cada cotización reciente, con dedup', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }])
const { res, json } = resFake()
await alertasInventarioEndpoint(
{ body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }] } } as unknown as Request,
res,
)
expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 1, omitidas: 0, fallidas: 0 })
expect(prismaMock.actividadLocal.create).toHaveBeenCalledWith({
data: expect.objectContaining({
titulo: 'La unidad U-101 que cotizaste pasó a APARTADA',
usuarioId: 'usr-1',
}),
})
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { clave: 'alerta-inv:U-101:cot-9:APARTADA' },
}),
)
})
it('sin vendedor en la cotización: omitida (sin actividad)', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: null }])
const { res, json } = resFake()
await alertasInventarioEndpoint(
{ body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'VENDIDA' }] } } as unknown as Request,
res,
)
expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 1, fallidas: 0 })
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled()
})
it('ya alertada (dedup persistente): omitida', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }])
prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'x', valor: 'y' })
const { res, json } = resFake()
await alertasInventarioEndpoint(
{ body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }] } } as unknown as Request,
res,
)
expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 1, fallidas: 0 })
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled()
})
})

View File

@@ -1,15 +1,14 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { mondayService, type DatosOportunidad } from '../services/monday.service.js'
import { obtenerUnidadConFallback } from '../services/unidades-local.service.js'
import { funnelService } from '../services/funnel.service.js'
import { getUnidadLocal } from '../services/unidades-local.service.js'
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
import {
procesarAlertasInventario,
type ResultadoAlertas,
} from '../services/alertas-inventario.service.js'
import { prisma } from '../services/prisma.js'
import { encolarSyncSeguro } from '../services/crm-local.service.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import { actividadLocalService } from '../services/actividad-local.service.js'
import { NotFoundError } from '../utils/errors.js'
import * as auditoria from '../services/auditoria.service.js'
import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
@@ -26,35 +25,36 @@ const crearCotizacionSchema = z.object({
// Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a
// esquemas con crédito (bancario/Infonavit)
enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(),
// Item del tablero Oportunidades al que queda vinculada la cotización
// (botón "Cotizar" de Mi día manda el id; sin él se busca por teléfono)
// Oportunidad a la que queda vinculada la cotización (botón "Cotizar" de Mi
// día manda el id; sin él se busca por teléfono)
oportunidadId: z.string().trim().min(1).optional(),
})
const hoyISO = (): string => {
const d = new Date()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
/**
* Oportunidad local que tenga ese teléfono (match de cotizaciones). Mismo
* criterio que el funnel: primero el mapping histórico (lead → su
* oportunidad); si no hay lead con ese número, la captura directa en
* Oportunidades (modelo nuevo: la afluencia ES la oportunidad).
*/
async function oportunidadLocalPorTelefono(telefono: string): Promise<string | null> {
const tel = telefono.replace(/\D/g, '').slice(-10)
if (tel.length < 7) return null
const captura = await prisma.leadCaptura.findFirst({
where: { telefono: { contains: tel } },
orderBy: { updatedAt: 'desc' },
})
if (!captura) return null
const mapeada = await prisma.leadOportunidad.findUnique({ where: { leadId: captura.leadId } })
if (mapeada) return mapeada.oportunidadId
return captura.board === 'OPORTUNIDADES' ? captura.leadId : null
}
// Labels reales de la columna status "Tipo de Propuesta" del tablero
// Oportunidades (verificados en Monday: difieren de los códigos del cotizador
// en guiones/espacios — el resolver case-insensitive solo no alcanza)
export const LABEL_PROPUESTA_MONDAY: Record<string, string> = {
BANCARIO: 'CREDITO BANCARIO',
INFONAVIT: 'INFONAVIT-COFINAVIT',
COFINAVIT: 'INFONAVIT-COFINAVIT',
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI CONTADO',
}
/** POST /api/cotizaciones — calcula, persiste y registra la oportunidad en Monday */
/** POST /api/cotizaciones — calcula, persiste en SQLite y liga la oportunidad local */
export async function crearCotizacion(req: Request, res: Response): Promise<void> {
const body = crearCotizacionSchema.parse(req.body)
// Monday caído (o 502) → espejo local (tabla Unidad); 404 solo si no existe en ninguno
const unidad = await obtenerUnidadConFallback(body.unidadCodigo)
// Inventario: SIEMPRE el espejo local (tabla Unidad); 404 si no existe
const unidad = await getUnidadLocal(body.unidadCodigo)
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
@@ -82,52 +82,27 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
},
})
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó
// persistida y la vinculación queda encolada (COTIZACION_OPORTUNIDAD) para
// que el worker la aplique cuando Monday vuelva
// Vinculación con la oportunidad local: explícita (botón "Cotizar" de Mi
// día) o por teléfono; la oportunidad ligada pasa a etapa 'Cotizador'.
let mondayItemId: string | null = null
const datosOportunidad: DatosOportunidad = {
clienteNombre: body.clienteNombre,
unidadCodigo: unidad.codigo,
tipoPropuestaLabel: LABEL_PROPUESTA_MONDAY[body.tipoPropuesta] ?? body.tipoPropuesta,
precioLista,
descuentoPct: propuesta.descuento * 100,
precioFinal: propuesta.precioFinal,
fechaCotizacion: hoyISO(),
estadoLabel: 'Cotizador', // etapa del funnel CRM al nacer la oportunidad
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
}
try {
// Si el teléfono corresponde a un lead del funnel, se ACTUALIZA su
// oportunidad (etapa 'Cotizador') en vez de crear una segunda. Con
// oportunidadId explícito (botón "Cotizar" de Mi día) se actualiza ESE
// item directamente y la cotización queda vinculada al cliente.
const opExistente =
body.oportunidadId !== undefined
? body.oportunidadId
: 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)
}
const opExistente =
body.oportunidadId !== undefined
? body.oportunidadId
: body.clienteTelefono !== undefined
? await oportunidadLocalPorTelefono(body.clienteTelefono)
: null
if (opExistente !== null) {
mondayItemId = opExistente
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo crear la oportunidad en Monday:', err)
await encolarSyncSeguro(
'COTIZACION_OPORTUNIDAD',
{
datos: datosOportunidad,
...(body.oportunidadId !== undefined ? { oportunidadId: body.oportunidadId } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
},
cotizacion.id,
)
try {
await crmLocal.cambiarEstado(opExistente, 'Cotizador', 'OPORTUNIDADES', {
sombra: !esIdLocal(opExistente),
usuarioId: req.usuarioToken?.sub,
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo pasar a 'Cotizador' la oportunidad ${opExistente}:`, err)
}
}
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })
@@ -210,9 +185,9 @@ const alertasInventarioSchema = z.object({
/**
* POST /api/cotizaciones/alertas-inventario — lo invoca el sync de estatus al
* detectar cambios (ver README). Crea una Actividad en Monday al vendedor de
* cada cotización reciente (≤30 días) de la unidad afectada, con dedup
* persistente por unidad+cotización+estatus.
* detectar cambios (ver README). Crea una Actividad local al vendedor de cada
* cotización reciente (≤30 días) de la unidad afectada, con dedup persistente
* por unidad+cotización+estatus.
*/
export async function alertasInventarioEndpoint(req: Request, res: Response): Promise<void> {
const { cambios } = alertasInventarioSchema.parse(req.body)
@@ -224,14 +199,14 @@ export async function alertasInventarioEndpoint(req: Request, res: Response): Pr
where: { unidadCodigo, createdAt: { gte: desde } },
select: { id: true, vendedorId: true },
}),
mondayUserIdDeVendedor: async (vendedorId) => {
const usuario = await prisma.usuario.findUnique({
where: { id: vendedorId },
select: { mondayUserId: true },
crearActividad: async (datos) => {
const actividad = await actividadLocalService.crearActividad({
titulo: datos.nombre,
usuarioId: datos.vendedorId,
tipo: 'RECORDATORIO',
})
return usuario?.mondayUserId ?? null
return actividad.id
},
crearActividad: (datos) => mondayService.crearActividad(datos),
yaAlertada: async (clave) => {
const estado = await prisma.automatizacionState.findUnique({ where: { clave } })
return estado !== null

View File

@@ -1,8 +1,6 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
/** GET /api/health — ping barato a Monday */
/** GET /api/health — verificación de vida del API */
export async function health(_req: Request, res: Response): Promise<void> {
const mondayUp = await mondayService.ping()
res.json({ status: 'ok', monday: mondayUp ? 'up' : 'down' })
res.json({ status: 'ok' })
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,32 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.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 { VOCABULARIO } from '../config/vocabulario.js'
import { prisma } from '../services/prisma.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
import { NotFoundError } from '../utils/errors.js'
import { AppError, NotFoundError } from '../utils/errors.js'
/**
* Captura y acciones del CRM ligero, sobre DOS tableros:
* - Tablero Leads (POST /api/leads): captura rápida de /hoy y captura manual
* desde la UI de Monday. Item ligero (nombre, teléfono, email, origen,
* desarrollo, notas). Los leads NO crean oportunidad.
* - Tablero Oportunidades (POST /api/afluencias): el Alta Afluencia (forma
* completa de /client) — la afluencia ES la oportunidad en etapa
* 'Afluencia', con todo el perfil.
* Captura y acciones del CRM ligero, sobre DOS tableros locales (tabla
* LeadCaptura de Prisma/SQLite):
* - Leads (POST /api/leads): captura rápida de /hoy y captura manual desde la
* UI. Item ligero (nombre, teléfono, email, origen, desarrollo, notas). Los
* leads NO crean oportunidad.
* - Oportunidades (POST /api/afluencias): el Alta Afluencia (forma completa de
* /client) — la afluencia ES la oportunidad en etapa 'Afluencia', con todo
* el perfil.
* Las acciones del vendedor (/estado, /descartar) y la búsqueda del picker del
* alta conviven con ambos tableros vía el parámetro `fuente`.
* Los labels se validan/resuelven contra los reales de cada board (los
* dropdown y status de Monday no auto-crean labels al escribir).
* Los labels de origen/desarrollo se validan contra el vocabulario canónico
* del CRM (config/vocabulario.ts).
*/
// Transiciones que el vendedor puede hacer desde la app Mi día
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO'] as const
// Traducción de la whitelist (valores que mandan los frontends) a los labels
// reales de la columna status 'Estado' de cada board
// Traducción de la whitelist (valores que mandan los frontends) a las etapas
// locales de cada board
const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
@@ -42,7 +37,7 @@ const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], strin
const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
INTERESADO: 'Interesado', // no existe en el board Oportunidades: se rechaza abajo
INTERESADO: 'Interesado', // no existe en Oportunidades: se rechaza abajo
'AGENDA CITA': 'Agendo Cita',
CALIFICADO: 'Calificado',
}
@@ -72,8 +67,7 @@ const crearAfluenciaSchema = z.object({
// Con oportunidadId (botón "Afluencia" de Mis oportunidades en Mi día): se
// ACTUALIZA esa oportunidad con el perfil capturado en vez de crear una nueva
oportunidadId: z.string().trim().min(1).optional(),
// Campos de calificación de la afluencia (opcionales; los labels fijos del
// front coinciden con los sembrados en Monday, no se validan contra el board)
// Campos de calificación de la afluencia (opcionales)
modelo: z.string().trim().min(1).optional(),
numDependientes: z.string().optional(),
edad: z.coerce.number().int().min(15).max(120).optional(),
@@ -100,7 +94,7 @@ const cambiarEstadoSchema = z.object({
const descartarSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA),
// Subcategoría del catálogo de Monday (debe pertenecer a la categoría elegida)
// Subcategoría del catálogo (debe pertenecer a la categoría elegida)
subcategoria: z.string().trim().min(1).optional(),
comentario: z.string().trim().min(1).optional(),
fuente: fuenteSchema,
@@ -109,377 +103,57 @@ const descartarSchema = z.object({
{ message: 'La subcategoría no pertenece a la categoría elegida', path: ['subcategoria'] },
)
// ---------- Búsqueda dual (prellenado del alta de clientes) ----------
const LIMITE_BUSQUEDA_LEADS = 15
/** Minúsculas y sin diacríticos, para comparar nombres/teléfonos con acentos */
const sinAcentos = (s: string): string =>
s
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
.toLowerCase()
/** Resultado del picker del alta: conviven Leads (captura ligera) y Oportunidades (alta afluencia) */
export interface ResultadoBusquedaLead {
id: string
nombre: string
telefono: string
email: string
estado: string
origen: string
fuente: FuenteLead
/** Desarrollo de interés ('' si no tiene); prellena afluencia/cotizador */
desarrollo: string
/** Modelo de interés ('' si no tiene) */
modeloInteres: string
}
/**
* Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en
* AMBOS tableros — Leads y Oportunidades —, ordena por actualización reciente
* y limita a 15 resultados combinados.
*/
export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonday[], q: string): ResultadoBusquedaLead[] {
const needle = sinAcentos(q)
const candidatos = [
...leads.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
desarrollo: l.desarrollo ?? '',
modeloInteres: l.modeloInteres ?? '',
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,
desarrollo: o.desarrollo ?? '',
modeloInteres: o.modeloInteres ?? '',
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'
| 'actualizarOportunidadAfluencia'
| 'crearMotivoNoCompra'
| 'buscarLeadPorTelefono'
| 'buscarOportunidadPorTelefono'
| 'getLeads'
| 'getOportunidades'
| 'labelsDeColumna'
| 'obtenerNombreItem'
| 'columnaIdPorTitulo'
| 'getItemConGrupo'
| 'moverItemAGrupo'
>
export type ResultadoCrearLead =
| { id: string; oportunidadId: string | null; actualizada?: boolean }
| { duplicado: { id: string; nombre: string } }
/**
* Crea un lead en el tablero Leads (Estado NUEVO + Fecha de hoy; captura
* rápida ligera, SIN oportunidad). Si viene teléfono y ya existe un lead con
* ese número, no crea nada y devuelve { duplicado } (el endpoint → 409).
* Origen y desarrollo se validan contra los labels reales del board Leads.
* Validación de origen/desarrollo contra el vocabulario canónico del CRM
* (config/vocabulario.ts). Modo estricto (captura rápida): 400 con los labels
* válidos. Modo tolerante (alta afluencia): el label desconocido se omite en
* vez de fallar.
*/
export async function crearLead(
monday: MondayLeads,
datos: z.infer<typeof crearLeadSchema>,
): Promise<ResultadoCrearLead> {
const boardLeads = config.monday.boards.leads
if (datos.telefono !== undefined) {
const existente = await monday.buscarLeadPorTelefono(datos.telefono)
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
}
// Dedup por email en ambos tableros
if (datos.email !== undefined && datos.email.trim() !== '') {
const [leads, oportunidades] = await Promise.all([
monday.getLeads(),
monday.getOportunidades(),
])
const emailLower = datos.email.trim().toLowerCase()
const existenteLead = leads.find((l) => l.email?.toLowerCase() === emailLower)
if (existenteLead) return { duplicado: { id: existenteLead.id, nombre: existenteLead.nombre } }
const existenteOp = oportunidades.find((o) => o.email?.toLowerCase() === emailLower)
if (existenteOp) return { duplicado: { id: existenteOp.id, nombre: existenteOp.nombre } }
}
if (datos.origen !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Origen')
if (!labels.includes(datos.origen)) {
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
function validarOrigenDesarrollo(
datos: { origen?: string | undefined; desarrollo?: string | undefined },
modo: 'estricto' | 'tolerante',
): { origen?: string | undefined; desarrollo?: string | undefined } {
let { origen, desarrollo } = datos
if (origen !== undefined && !(VOCABULARIO.ORIGENES as readonly string[]).includes(origen)) {
if (modo === 'estricto') {
throw new AppError(
400,
`Origen inválido: "${origen}". Labels válidos: ${VOCABULARIO.ORIGENES.join(', ')}`,
)
}
}
if (datos.desarrollo !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, '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({
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
origenLabel: datos.origen,
desarrolloLabel: datos.desarrollo,
notas: datos.notas,
})
return { id, oportunidadId: null }
}
/**
* 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 crearAfluencia(
monday: MondayLeads,
datos: z.infer<typeof crearAfluenciaSchema>,
): Promise<ResultadoCrearLead> {
const boardOportunidades = config.monday.boards.oportunidades
// Con oportunidadId: el formulario venía prellenado desde Mis oportunidades
// (Mi día) → se ACTUALIZA esa oportunidad con el perfil completo y queda en
// etapa 'Afluencia'; no se crea duplicado ni se valida teléfono.
if (datos.oportunidadId !== undefined) {
await monday.actualizarOportunidadAfluencia(datos.oportunidadId, {
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: datos.desarrollo,
origenLabel: datos.origen,
modeloLabel: datos.modelo,
numDependientes: datos.numDependientes,
edad: datos.edad,
estadoCivilLabel: datos.estadoCivil,
tipoEmpleoLabel: datos.tipoEmpleo,
ocupacion: datos.ocupacion,
rangoSalarial: datos.rangoSalarial,
creditoLabel: datos.credito,
tipoEconomiaLabel: datos.tipoEconomia,
calificaLabel: datos.califica,
loQuierePara: datos.loQuierePara,
notas: datos.notas,
})
try {
await moverAGrupoSegunEstado(monday, boardOportunidades, datos.oportunidadId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover al grupo correcto la oportunidad ${datos.oportunidadId}:`, err)
}
return { id: datos.oportunidadId, oportunidadId: datos.oportunidadId, actualizada: true }
}
if (datos.telefono !== undefined) {
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
}
// Validación tolerante: si el label no existe en el board, se omite en vez
// de fallar. Esto evita 400 cuando Monday aún no propagó un label nuevo.
let origenSafe = datos.origen
if (origenSafe !== undefined) {
const labels = await monday.labelsDeColumna(boardOportunidades, 'Origen del Lead')
if (!labels.includes(origenSafe)) {
console.warn(`Origen "${origenSafe}" no es label del board; se omite`)
origenSafe = undefined
}
}
let desarrolloSafe = datos.desarrollo
if (desarrolloSafe !== undefined) {
const labels = await monday.labelsDeColumna(boardOportunidades, 'Desarrollo')
if (!labels.includes(desarrolloSafe)) {
console.warn(`Desarrollo "${desarrolloSafe}" no es label del board; se omite`)
desarrolloSafe = undefined
}
}
const id = await monday.crearOportunidadAfluencia({
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: desarrolloSafe,
origenLabel: origenSafe,
modeloLabel: datos.modelo,
numDependientes: datos.numDependientes,
edad: datos.edad,
estadoCivilLabel: datos.estadoCivil,
tipoEmpleoLabel: datos.tipoEmpleo,
ocupacion: datos.ocupacion,
rangoSalarial: datos.rangoSalarial,
creditoLabel: datos.credito,
tipoEconomiaLabel: datos.tipoEconomia,
calificaLabel: datos.califica,
loQuierePara: datos.loQuierePara,
notas: datos.notas,
})
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)
console.warn(`Origen "${origen}" no está en el vocabulario; se omite`)
origen = undefined
}
return { id, oportunidadId: id }
}
/**
* Cambia el estado del item (whitelist → label real del board correspondiente
* según `fuente`); en AGENDA CITA puede setear la columna Fecha.
*/
export async function cambiarEstadoLead(
monday: MondayLeads,
itemId: string,
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
fecha?: string,
fuente: FuenteLead = 'oportunidad',
): Promise<void> {
const esLead = fuente === 'lead'
// 'Interesado' solo existe en el tablero Leads (no es etapa del funnel)
if (estado === 'INTERESADO' && !esLead) {
throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads')
}
const boardId = esLead ? config.monday.boards.leads : config.monday.boards.oportunidades
const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado]
await monday.cambiarValorColumna(boardId, itemId, 'Estado', label)
if (estado === 'AGENDA CITA' && fecha !== undefined) {
await monday.cambiarValorColumna(boardId, itemId, 'Fecha', fecha)
}
}
/**
* Descarte del item según `fuente`:
* - 'oportunidad': Estado 'Perdido' + dropdown Motivo No Compra (label ya
* sembrado; si no existiera se omite) + comentario en COMENTARIOS.
* - 'lead': Estado 'Descartado' + comentario en Notas.
* Ambos registran el motivo (y la subcategoría si viene) en el tablero
* catálogo Motivos No Compra.
*/
export async function descartarLead(
monday: MondayLeads,
itemId: string,
motivo: string,
comentario?: string,
fuente: FuenteLead = 'oportunidad',
subcategoria?: string,
): Promise<string> {
const nombreItem = await monday.obtenerNombreItem(itemId)
if (fuente === 'lead') {
const boardLeads = config.monday.boards.leads
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'Descartado')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
if (desarrollo !== undefined && !(VOCABULARIO.DESARROLLOS as readonly string[]).includes(desarrollo)) {
if (modo === 'estricto') {
throw new AppError(
400,
`Desarrollo inválido: "${desarrollo}". Labels válidos: ${VOCABULARIO.DESARROLLOS.join(', ')}`,
)
}
} else {
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
}
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}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`,
categoria: motivo,
...(subcategoria !== undefined ? { subcategoria } : {}),
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
}
/**
* Self-assign del capturista con bandera anti-carrera rr:skip (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 — y queda
* encolada como ASIGNAR_PERSONA para reintentarla cuando Monday vuelva).
* Devuelve cómo quedó la asignación.
*/
async function asignarCapturistaSiAplica(
usuarioId: string | undefined,
itemId: string,
nombre: string,
board: 'leads' | 'oportunidades',
): Promise<'capturista' | 'round-robin'> {
if (usuarioId === undefined) return 'round-robin'
try {
const capturista = await prisma.usuario.findUnique({
where: { id: usuarioId },
select: { mondayUserId: true },
})
if (capturista?.mondayUserId != null) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'capturista')
try {
await automatizacionesService.asignarACapturista(itemId, nombre, capturista.mondayUserId, board)
return 'capturista'
} catch (err) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'round-robin')
// Best-effort: el worker la reintenta al drenar la cola
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: capturista.mondayUserId }, itemId)
throw err
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo asignar el item ${itemId} al capturista:`, err)
console.warn(`Desarrollo "${desarrollo}" no está en el vocabulario; se omite`)
desarrollo = undefined
}
return 'round-robin'
return { origen, desarrollo }
}
/** POST /api/leads { nombre, telefono?, email?, origen?, desarrollo?, notas? } — captura rápida (tablero Leads) */
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = crearLeadSchema.parse(req.body)
const { origen, desarrollo } = validarOrigenDesarrollo(body, 'estricto')
const usuarioSub = req.usuarioToken?.sub
// Modo local: Monday caído (flag) → directo a crmLocal; Monday vivo → flujo
// actual con fallback a local si la llamada lanza 502. La respuesta mantiene
// la misma forma en ambos caminos (el front no se entera).
let modoLocal = !saludMonday.estaDisponible()
let resultado: ResultadoCrearLead
if (modoLocal) {
resultado = await crmLocal.crearLead(body, usuarioSub)
} else {
try {
resultado = await crearLead(mondayService, body)
} catch (err) {
if (!esFalloMonday(err)) throw err
modoLocal = true
resultado = await crmLocal.crearLead(body, usuarioSub)
}
}
// SIEMPRE local: fila LeadCaptura board=LEADS etapa NUEVO + CREAR_LEAD
// encolado. Sin usuario el capturista queda para el reparto (round-robin).
const resultado: ResultadoCrearLead = await crmLocal.crearLead(
{ ...body, origen, desarrollo },
usuarioSub,
)
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
@@ -488,60 +162,26 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
})
return
}
if (modoLocal) {
// Sin round-robin en modo local: el capturista queda asignado (su
// ASIGNAR_PERSONA viaja encolada tras el CREATE)
res.status(201).json({
id: resultado.id,
estado: 'NUEVO',
oportunidadId: null,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
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.
if (usuarioSub) {
try {
await prisma.leadCaptura.create({
data: {
leadId: resultado.id,
oportunidadId: null, // los leads ya no generan oportunidad
usuarioId: usuarioSub,
nombreLead: body.nombre,
telefono: body.telefono ?? null,
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
}
}
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'leads')
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion })
res.status(201).json({
id: resultado.id,
estado: 'NUEVO',
oportunidadId: null,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_LEAD', 'lead', resultado.id)
}
/** POST /api/afluencias { nombre, telefono?, ..., perfil completo } — Alta Afluencia (tablero Oportunidades) */
export async function crearAfluenciaEndpoint(req: Request, res: Response): Promise<void> {
const body = crearAfluenciaSchema.parse(req.body)
const { origen, desarrollo } = validarOrigenDesarrollo(body, 'tolerante')
const usuarioSub = req.usuarioToken?.sub
// Modo local: Monday caído (flag) → crmLocal (crear o actualizar); fallback
// a local tras 502. Misma forma de respuesta en ambos caminos.
let modoLocal = !saludMonday.estaDisponible()
let resultado: ResultadoCrearLead
if (modoLocal) {
resultado = await crmLocal.crearAfluencia(body, usuarioSub)
} else {
try {
resultado = await crearAfluencia(mondayService, body)
} catch (err) {
if (!esFalloMonday(err)) throw err
modoLocal = true
resultado = await crmLocal.crearAfluencia(body, usuarioSub)
}
}
// SIEMPRE local: con oportunidadId se ACTUALIZA esa fila; sin él se crea la
// afluencia (que ES la oportunidad) con auto-mapping LeadOportunidad.
const resultado: ResultadoCrearLead = await crmLocal.crearAfluencia(
{ ...body, origen, desarrollo },
usuarioSub,
)
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
@@ -551,58 +191,24 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
return
}
// Actualización de oportunidad existente (botón "Afluencia" de Mi día): no
// hay captura nueva que registrar ni round-robin que correr
// hay captura nueva que registrar
if (resultado.actualizada === true) {
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
return
}
if (modoLocal) {
res.status(201).json({
id: resultado.id,
estado: 'Afluencia',
oportunidadId: resultado.oportunidadId,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día.
// leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un
// fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday.
if (usuarioSub) {
try {
await prisma.leadCaptura.create({
data: {
leadId: resultado.id,
oportunidadId: resultado.oportunidadId,
usuarioId: usuarioSub,
nombreLead: body.nombre,
telefono: body.telefono ?? null,
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo registrar la captura de la afluencia ${resultado.id}:`, err)
}
}
// 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 ?? resultado.id },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err)
}
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'oportunidades')
res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion })
res.status(201).json({
id: resultado.id,
estado: 'Afluencia',
oportunidadId: resultado.oportunidadId,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_AFLUENCIA', 'afluencia', resultado.id)
}
/**
* Cambio de estado en modo local: guarda la etapa (label real del board) en la
* fila local y encola CAMBIO_ETAPA. Con id Monday sin fila local (fallback 502
* sobre items viejos) crea la fila "sombra" para que Mi día lo refleje.
* Cambio de estado en modo local: guarda la etapa (label del board) en la fila
* local. Con id heredado sin fila local (items viejos del CRM anterior)
* crea la fila "sombra" para que Mi día lo refleje.
*/
async function cambiarEstadoLocal(
itemId: string,
@@ -627,19 +233,7 @@ async function cambiarEstadoLocal(
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const fuente = body.fuente ?? 'oportunidad'
// Los ids loc_* siempre van al path local (aún no existen en Monday); con el
// flag caído todo va a local; con Monday vivo, fallback a local tras 502.
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub)
} else {
try {
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente)
} catch (err) {
if (!esFalloMonday(err)) throw err
await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub)
}
}
await cambiarEstadoLocal(itemId, body.estado, body.fecha, body.fuente ?? 'oportunidad', req.usuarioToken?.sub)
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'lead', itemId, body.estado)
}
@@ -648,31 +242,19 @@ 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 fuente = body.fuente ?? 'oportunidad'
let motivoItemId: string | null = null
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await crmLocal.descartar(itemId, body.motivo, {
fuente,
subcategoria: body.subcategoria,
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
} else {
try {
motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.descartar(itemId, body.motivo, {
fuente,
subcategoria: body.subcategoria,
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
}
}
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
await crmLocal.descartar(itemId, body.motivo, {
fuente: body.fuente ?? 'oportunidad',
subcategoria: body.subcategoria,
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
res.json({
itemId,
estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido',
motivo: body.motivo,
motivoItemId: null,
})
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'DESCARTAR_LEAD', 'lead', itemId, body.motivo)
}
@@ -686,117 +268,19 @@ const convertirSchema = z.object({
})
/**
* Convierte un lead del tablero Leads en oportunidad (botón "Agendo" de Mis
* leads): crea el item en Oportunidades con etapa 'Agendo Cita' y los datos
* del lead (los labels se omiten si no existen en el board destino), asigna
* como Asesor al vendedor del lead (o a quien convierte), marca el lead como
* 'Calificado' (sale de Mis leads) y guarda el mapping LeadOportunidad para
* que el alta de clientes la encuentre. Si el lead ya fue convertido → 409.
* Convierte un lead en oportunidad (botón "Agendo" de Mis leads): el lead
* queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando
* los datos y se guarda el mapping LeadOportunidad. Si el lead ya fue
* convertido → 409. La fecha de la cita viaja en el CAMBIO_ETAPA posterior del
* front.
*/
export async function convertirLead(
monday: MondayLeads,
itemId: string,
usuarioId?: string,
fecha?: string,
): Promise<{ oportunidadId: string }> {
const existente = await prisma.leadOportunidad.findUnique({ where: { leadId: itemId } })
if (existente) throw new AppError(409, 'El lead ya fue convertido en oportunidad')
const lead = (await monday.getLeads()).find((l) => l.id === itemId)
if (!lead) throw new NotFoundError('Lead no encontrado en el tablero Leads')
const boardOportunidades = config.monday.boards.oportunidades
// Los labels solo se copian si existen en el board destino (dropdown/status
// no auto-crean labels al escribir)
const [labelsOrigen, labelsDesarrollo, labelsModelo] = await Promise.all([
monday.labelsDeColumna(boardOportunidades, 'Origen del Lead'),
monday.labelsDeColumna(boardOportunidades, 'Desarrollo'),
monday.labelsDeColumna(boardOportunidades, 'Modelo de Interés'),
])
const oportunidadId = await monday.crearOportunidadAfluencia({
nombre: lead.nombre,
estadoLabel: 'Agendo Cita',
telefono: lead.telefono || undefined,
email: lead.email || undefined,
origenLabel: lead.origen && labelsOrigen.includes(lead.origen) ? lead.origen : undefined,
desarrolloLabel: lead.desarrollo && labelsDesarrollo.includes(lead.desarrollo) ? lead.desarrollo : undefined,
modeloLabel: lead.modeloInteres && labelsModelo.includes(lead.modeloInteres) ? lead.modeloInteres : undefined,
})
if (fecha !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Fecha', fecha)
}
// Asesor de la oportunidad: el vendedor del lead, o quien la convierte
let asesorId = lead.vendedorIds[0]
if (asesorId === undefined && usuarioId !== undefined) {
const usuario = await prisma.usuario.findUnique({ where: { id: usuarioId }, select: { mondayUserId: true } })
asesorId = usuario?.mondayUserId ?? undefined
}
if (asesorId !== undefined) {
// Bandera anti-carrera: el webhook create_item no debe round-robinnearla
await prismaStateStore.set(`${CLAVE_RR_SKIP}${oportunidadId}`, 'capturista')
try {
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Asesor', asesorId)
} catch (err) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${oportunidadId}`, 'round-robin')
throw err
}
}
// El lead queda Calificado (convertido: sale de Mis leads) y ambos items a su grupo
await monday.cambiarValorColumna(config.monday.boards.leads, itemId, 'Estado', 'Calificado')
await moverAGrupoSegunEstado(monday, config.monday.boards.leads, itemId)
await moverAGrupoSegunEstado(monday, boardOportunidades, oportunidadId)
await prisma.leadOportunidad.create({ data: { leadId: itemId, oportunidadId } })
return { oportunidadId }
}
/** POST /api/leads/:itemId/convertir { fecha? } */
export async function convertirLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = convertirSchema.parse(req.body ?? {})
convertirSchema.parse(req.body ?? {})
const itemId = String(req.params['itemId'])
const usuarioSub = req.usuarioToken?.sub
// Modo local (id loc_* o Monday caído / fallback 502): la conversión crea la
// oportunidad local en 'Agendo Cita' y encola CONVERSION. Nota: en el
// fallback la fecha de la cita viaja en el CAMBIO_ETAPA posterior del front,
// como en el flujo normal.
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
return
}
try {
const { oportunidadId } = await convertirLead(mondayService, itemId, usuarioSub, body.fecha)
// Crear LeadCaptura local para la oportunidad (Mi día las necesita)
try {
const leadLocal = await prisma.leadCaptura.findFirst({ where: { leadId: itemId } })
await prisma.leadCaptura.upsert({
where: { leadId: oportunidadId },
update: { board: 'OPORTUNIDADES', etapa: 'Agendo Cita' },
create: {
leadId: oportunidadId,
board: 'OPORTUNIDADES',
etapa: 'Agendo Cita',
nombreLead: leadLocal?.nombreLead ?? '',
telefono: leadLocal?.telefono ?? null,
email: leadLocal?.email ?? null,
usuarioId: usuarioSub ?? 'sistema',
mondayItemId: oportunidadId,
pendienteSync: false,
},
})
} catch { /* best effort */ }
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
} catch (err) {
if (!esFalloMonday(err)) throw err
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
}
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
}
// ── Admin CRUD de leads ────────────────────────────────────────────────
@@ -804,19 +288,8 @@ export async function convertirLeadEndpoint(req: Request, res: Response): Promis
/** DELETE /api/leads/:itemId — eliminar lead (solo admin) */
export async function eliminarLeadEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
// Eliminar de Monday si es item remoto y Monday está disponible
if (row && row.mondayItemId && !esIdLocal(itemId) && saludMonday.estaDisponible()) {
try {
await mondayService.eliminarItem(row.mondayItemId)
} catch (err) {
if (!esFalloMonday(err)) throw err
}
}
// Eliminar la fila local (si existe)
if (row) {
await prisma.leadCaptura.delete({ where: { leadId: itemId } }).catch(() => {})
}
await prisma.leadCaptura.delete({ where: { leadId: itemId } }).catch(() => {})
res.json({ eliminado: true, itemId })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ELIMINAR_LEAD', 'lead', itemId)
}
@@ -831,27 +304,14 @@ export async function asignarLeadEndpoint(req: Request, res: Response): Promise<
const { usuarioId } = asignarLeadSchema.parse(req.body)
const usuario = await prisma.usuario.findUnique({
where: { id: usuarioId },
select: { mondayUserId: true, nombre: true },
select: { nombre: true },
})
if (!usuario) throw new NotFoundError('Usuario no encontrado')
// Actualizar LeadCaptura
// La asignación local es la verdad: se actualiza la fila del lead
await prisma.leadCaptura.updateMany({
where: { leadId: itemId },
data: { usuarioId },
})
// En Monday: determinar si es lead u oportunidad
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
const board: 'leads' | 'oportunidades' = row?.board === 'OPORTUNIDADES' ? 'oportunidades' : 'leads'
if (usuario.mondayUserId != null && saludMonday.estaDisponible() && !esIdLocal(itemId)) {
try {
await automatizacionesService.asignarACapturista(itemId, row?.nombreLead ?? '', usuario.mondayUserId, board)
} catch (err) {
if (!esFalloMonday(err)) throw err
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: usuario.mondayUserId }, itemId)
}
} else if (usuario.mondayUserId != null) {
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: usuario.mondayUserId }, itemId)
}
res.json({ itemId, usuarioId, nombre: usuario.nombre })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, usuarioId)
}
@@ -876,17 +336,11 @@ export async function moverLeadEndpoint(req: Request, res: Response): Promise<vo
/**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2
* clientes. Busca en las tablas locales (LeadCaptura + Cliente); q mínimo 2
* caracteres; devuelve máximo 15, cada uno con su `fuente`.
* Con Monday caído busca en lo local (LeadCaptura + Cliente), misma forma.
*/
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')
if (!saludMonday.estaDisponible()) {
res.json(await crmLocal.buscarLocales(q))
return
}
const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()])
res.json(buscarLeads(leads, oportunidades, q))
res.json(await crmLocal.buscarLocales(q))
}

View File

@@ -10,7 +10,7 @@ import {
verificarLigaToken,
} from './liga.controller.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
// ---------- Prisma mockeado (los tests no tocan BD) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
@@ -23,12 +23,8 @@ const prismaMock = vi.hoisted(() => ({
},
}))
const mondayMock = vi.hoisted(() => ({
cambiarValorColumna: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
const SECRET = 'test-jwt-secret' // JWT_SECRET fijado en vitest.config.ts
@@ -220,10 +216,9 @@ describe('subirDocumentosPublico (POST /clientes/publico/:token/documentos)', ()
expect(status).toHaveBeenCalledWith(201)
})
it('prende el checkbox en Apartado cuando el cliente tiene mondayApartadoId', async () => {
it('persiste el documento aunque el cliente tenga mondayApartadoId (sin efectos Monday)', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake({ mondayApartadoId: 'ap-9' }))
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
mondayMock.cambiarValorColumna.mockResolvedValue(undefined)
const token = generarLigaToken('c-1')
await subirDocumentosPublico(
@@ -231,10 +226,10 @@ describe('subirDocumentosPublico (POST /clientes/publico/:token/documentos)', ()
resFake().res,
)
expect(mondayMock.cambiarValorColumna).toHaveBeenCalledWith(expect.any(String), 'ap-9', 'CURP', true)
expect(prismaMock.documento.create).toHaveBeenCalled()
})
it('no llama a Monday sin mondayApartadoId', async () => {
it('acepta tipos con cliente sin apartado ligado', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
const token = generarLigaToken('c-1')
@@ -244,6 +239,5 @@ describe('subirDocumentosPublico (POST /clientes/publico/:token/documentos)', ()
resFake().res,
)
expect(mondayMock.cambiarValorColumna).not.toHaveBeenCalled()
})
})

View File

@@ -6,10 +6,8 @@ import jwt from 'jsonwebtoken'
import multer from 'multer'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService } from '../services/monday.service.js'
import { prisma } from '../services/prisma.js'
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
import { checkboxParaTipoDocumento } from './clientes.controller.js'
// ---------- Liga remota de firma/documentos (modo público con token) ----------
@@ -214,21 +212,5 @@ export async function subirDocumentosPublico(req: Request, res: Response): Promi
),
)
// Mismo efecto que la subida interna: checkbox del documento en Apartado.
// Un fallo de Monday no rompe la subida.
const checkbox = checkboxParaTipoDocumento(tipo)
if (checkbox !== null && cliente.mondayApartadoId !== null) {
try {
await mondayService.cambiarValorColumna(
config.monday.boards.apartado,
cliente.mondayApartadoId,
checkbox,
true,
)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${cliente.mondayApartadoId}:`, err)
}
}
res.status(201).json(documentos)
}

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError } from '../utils/errors.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
// ---------- Prisma mockeado (los tests no tocan BD) ----------
const prismaMock = vi.hoisted(() => ({
usuario: {
@@ -20,17 +20,7 @@ const prismaMock = vi.hoisted(() => ({
},
}))
const mondayMock = vi.hoisted(() => ({
getUsuariosWorkspace: vi.fn(),
getLeads: vi.fn(),
getOportunidades: vi.fn(),
getActividades: vi.fn(),
getApartados: vi.fn(),
getUltimosCambios: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { resumenMiDiaEndpoint } from './mi-dia.controller.js'
@@ -81,16 +71,10 @@ beforeEach(() => {
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.actividadLocal.findMany.mockResolvedValue([])
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
mondayMock.getLeads.mockResolvedValue([])
mondayMock.getOportunidades.mockResolvedValue([])
mondayMock.getActividades.mockResolvedValue([])
mondayMock.getApartados.mockResolvedValue([])
mondayMock.getUltimosCambios.mockResolvedValue(new Map())
})
describe('resumenMiDiaEndpoint (siempre local, Monday purgado)', () => {
it('responde 100% desde LeadCaptura: Monday no se llama nunca', async () => {
describe('resumenMiDiaEndpoint (100% local)', () => {
it('responde 100% desde LeadCaptura', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }),
capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }),
@@ -99,10 +83,6 @@ describe('resumenMiDiaEndpoint (siempre local, Monday purgado)', () => {
await resumenMiDiaEndpoint(reqFake, res)
expect(mondayMock.getLeads).not.toHaveBeenCalled()
expect(mondayMock.getOportunidades).not.toHaveBeenCalled()
expect(mondayMock.getApartados).not.toHaveBeenCalled()
expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled()
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
expect((resumen['misLeads'] as unknown[]).length).toBe(1) // solo leads, no oportunidades
expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1)

View File

@@ -59,7 +59,7 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
}
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId }
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol }
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
}

View File

@@ -1,15 +1,10 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
cambiarEtapaOportunidad,
cambiarEtapaOportunidadEndpoint,
ETAPAS_OPORTUNIDAD_PERMITIDAS,
perderOportunidad,
perderOportunidadEndpoint,
} from './oportunidades.controller.js'
import { mondayService } from '../services/monday.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { AppError } from '../utils/errors.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
@@ -19,8 +14,8 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(),
update: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
unidad: {
updateMany: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
@@ -34,110 +29,81 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
/** Monday mockeado: registra los cambios de columna y los motivos creados */
function mondayMock() {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async obtenerNombreItem(_itemId: string) {
return 'Juan Pérez'
},
async crearMotivoNoCompra(datos: { nombre: string; categoria: string; descripcion?: string }) {
this.motivos.push(datos)
return 'motivo-item-1'
},
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
describe('perderOportunidad (POST /api/oportunidades/:id/perder)', () => {
it('pone etapa Perdido + dropdown motivo y registra en el tablero Motivos No Compra', async () => {
const monday = mondayMock()
const motivoItemId = await perderOportunidad(monday, 'opp-123', 'Precio', 'Se le hizo caro')
const reqCon = (overrides: Record<string, unknown>): Request =>
({
body: {},
params: {},
usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
...overrides,
}) as unknown as Request
expect(motivoItemId).toBe('motivo-item-1')
// Etapa y motivo sobre la oportunidad (board 555 = MONDAY_BOARD_OPORTUNIDADES de test)
expect(monday.cambios).toEqual([
{ boardId: '555', itemId: 'opp-123', columna: 'Estado', valor: 'Perdido' },
{ boardId: '555', itemId: 'opp-123', columna: 'Motivo No Compra', valor: 'Precio' },
])
// Item en el catálogo: nombre = motivo + item de origen
expect(monday.motivos).toEqual([
{ nombre: 'Precio — Juan Pérez', categoria: 'Precio', descripcion: 'Se le hizo caro' },
])
beforeEach(() => {
vi.clearAllMocks()
prismaMock.auditoriaLog.create.mockResolvedValue({})
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 })
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('perderOportunidadEndpoint (POST /api/oportunidades/:id/perder) — local', () => {
it('etapa Perdido + motivo/submotivo en la fila local', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_9', nombreLead: 'Luis Demo' })
const { res, json } = resFake()
await perderOportunidadEndpoint(
reqCon({ params: { mondayItemId: 'loc_9' }, body: { motivo: 'Precio', comentario: 'Caro' } }),
res,
)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_9' },
data: expect.objectContaining({ etapa: 'Perdido', motivo: 'Precio' }),
})
expect(json).toHaveBeenCalledWith({ mondayItemId: 'loc_9', etapa: 'Perdido', motivo: 'Precio', motivoItemId: null })
})
it('el comentario es opcional', async () => {
const monday = mondayMock()
await perderOportunidad(monday, 'opp-9', 'Competencia')
expect(monday.motivos[0]).toEqual({ nombre: 'Competencia — Juan Pérez', categoria: 'Competencia' })
it('el motivo debe ser del vocabulario (400 si no)', async () => {
const { res } = resFake()
await expect(
perderOportunidadEndpoint(
reqCon({ params: { mondayItemId: 'loc_9' }, body: { motivo: 'No le gustó' } }),
res,
),
).rejects.toThrow()
expect(prismaMock.leadCaptura.update).not.toHaveBeenCalled()
})
it('propaga el error si Monday falla (el endpoint responde 5xx)', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
await expect(perderOportunidad(monday, 'opp-1', 'Timing')).rejects.toThrow('Monday caído')
// No se registró motivo si falló el cambio de etapa
expect(monday.motivos).toEqual([])
it('id heredado sin fila local: crea la sombra y guarda el descarte', async () => {
const { res, json } = resFake()
await perderOportunidadEndpoint(
reqCon({ params: { mondayItemId: 'opp-77' }, body: { motivo: 'Timing' } }),
res,
)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ leadId: 'opp-77', board: 'OPORTUNIDADES', mondayItemId: 'opp-77' }),
})
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'opp-77', etapa: 'Perdido', motivoItemId: null })
})
})
/** Monday mockeado para cambio de etapa: registra cambios y movimientos de grupo */
function mondayEtapaMock(grupoActual = 'topics') {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
movimientos: [] as { itemId: string; grupo: string }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async columnaIdPorTitulo(_boardId: string, titulo: string) {
return titulo === 'Estado' ? 'status' : null
},
async getItemConGrupo(itemId: string) {
return {
id: itemId,
name: 'Cliente Demo',
groupId: grupoActual,
column_values: [{ id: 'status', text: this.cambios.find((c) => c.columna === 'Estado')?.valor as string ?? '', value: '{}' }],
}
},
async moverItemAGrupo(itemId: string, grupo: string) {
this.movimientos.push({ itemId, grupo })
},
}
}
describe('cambiarEtapaOportunidad (POST /api/oportunidades/:id/etapa)', () => {
it('cambia la etapa y mueve al grupo destino (Abandono → Perdidos)', async () => {
const monday = mondayEtapaMock()
await cambiarEtapaOportunidad(monday, 'opp-1', 'Abandono')
expect(monday.cambios).toEqual([
{ boardId: '555', itemId: 'opp-1', columna: 'Estado', valor: 'Abandono' },
])
expect(monday.movimientos).toEqual([{ itemId: 'opp-1', grupo: 'group_mm6s4cgq' }])
})
it('Agendo Cita setea también la columna Fecha', async () => {
const monday = mondayEtapaMock()
await cambiarEtapaOportunidad(monday, 'opp-2', 'Agendo Cita', '2026-09-10')
expect(monday.cambios).toEqual([
{ boardId: '555', itemId: 'opp-2', columna: 'Estado', valor: 'Agendo Cita' },
{ boardId: '555', itemId: 'opp-2', columna: 'Fecha', valor: '2026-09-10' },
])
// 'Agendo Cita' mapea al grupo topics: ya estaba ahí → no se mueve
expect(monday.movimientos).toEqual([])
})
it('sin fecha no toca la columna Fecha', async () => {
const monday = mondayEtapaMock()
await cambiarEtapaOportunidad(monday, 'opp-3', 'Cotizador')
expect(monday.cambios).toEqual([
{ boardId: '555', itemId: 'opp-3', columna: 'Estado', valor: 'Cotizador' },
])
})
describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa) — local', () => {
it('las etapas permitidas son las fases del funnel definidas por el cliente', () => {
expect(ETAPAS_OPORTUNIDAD_PERMITIDAS).toEqual([
'Agendo Cita',
@@ -148,64 +114,39 @@ describe('cambiarEtapaOportunidad (POST /api/oportunidades/:id/etapa)', () => {
'Abandono',
])
})
})
// ---------- Modo local (saludMonday caído / ids loc_* / fallback 502) ----------
describe('ruteo modo local (endpoints de oportunidades)', () => {
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const reqCon = (overrides: Record<string, unknown>): Request =>
({
body: {},
params: {},
usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
...overrides,
}) as unknown as Request
function tiposEncolados(): string[] {
return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo)
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.auditoriaLog.create.mockResolvedValue({})
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.syncPendiente.create.mockResolvedValue({})
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('cambiarEtapaOportunidadEndpoint con id loc_*: etapa local + CAMBIO_ETAPA encolado, sin tocar Monday', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true) // Monday vivo: el id loc_* manda
const spyCambiar = vi.spyOn(mondayService, 'cambiarValorColumna')
it('cambia la etapa en la fila local y encola CAMBIO_ETAPA', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' })
const { res, json } = resFake()
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { etapa: 'Cotizador' } }), res)
expect(spyCambiar).not.toHaveBeenCalled()
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_1' },
data: { etapa: 'Cotizador', pendienteSync: true },
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', etapa: 'Cotizador' })
})
it("cambiarEtapaOportunidadEndpoint 'Aparto' con Monday caído: crea el apartado local (crmLocal.apartar)", async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
it("'Agendo Cita' con fecha: la fecha queda en datosJson", async () => {
prismaMock.leadCaptura.findUnique
.mockResolvedValueOnce({ leadId: 'loc_2', board: 'OPORTUNIDADES' }) // filaLocal
.mockResolvedValueOnce({ leadId: 'loc_2', datosJson: null }) // guardar fechaCita
const { res, json } = resFake()
await cambiarEtapaOportunidadEndpoint(
reqCon({ params: { itemId: 'loc_2' }, body: { etapa: 'Agendo Cita', fecha: '2026-09-10' } }),
res,
)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_2' },
data: expect.objectContaining({ etapa: 'Agendo Cita', datosJson: JSON.stringify({ fechaCita: '2026-09-10' }) }),
})
expect(json).toHaveBeenCalledWith({ itemId: 'loc_2', etapa: 'Agendo Cita', fecha: '2026-09-10' })
})
it("'Aparto': crea el apartado local (crmLocal.apartar) y bloquea la unidad del inventario", async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op',
usuarioId: 'u-1',
@@ -214,7 +155,7 @@ describe('ruteo modo local (endpoints de oportunidades)', () => {
email: null,
desarrollo: 'ATELIER',
modelo: 'SOHO',
datosJson: null,
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
})
const { res, json } = resFake()
@@ -228,39 +169,37 @@ describe('ruteo modo local (endpoints de oportunidades)', () => {
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado', nombreLead: 'Luis Demo' }),
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA', 'CREAR_APARTADO'])
// La unidad asignada queda APARTADA en el espejo local
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'U-101' },
data: { status: 'APARTADA' },
})
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' })
})
it('cambiarEtapaOportunidadEndpoint con id Monday y 502: fallback local con fila sombra', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
vi.spyOn(mondayService, 'cambiarValorColumna').mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'opp-88' }, body: { etapa: 'No Asistio' } }), res)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ leadId: 'opp-88', board: 'OPORTUNIDADES', mondayItemId: 'opp-88' }),
it("'Aparto' sin unidad asignada no toca el inventario", async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op',
usuarioId: 'u-1',
nombreLead: 'Luis Demo',
telefono: null,
email: null,
desarrollo: null,
modelo: null,
datosJson: null,
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
expect(json).toHaveBeenCalledWith({ itemId: 'opp-88', etapa: 'No Asistio' })
const { res } = resFake()
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res)
expect(prismaMock.unidad.updateMany).not.toHaveBeenCalled()
})
it('perderOportunidadEndpoint con Monday caído: descarte local (motivoItemId null) + DESCARTE encolado', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_9', nombreLead: 'Luis Demo' })
const { res, json } = resFake()
it('etapa fuera del vocabulario → 400 (zod)', async () => {
const { res } = resFake()
await perderOportunidadEndpoint(
reqCon({ params: { mondayItemId: 'loc_9' }, body: { motivo: 'Precio', comentario: 'Caro' } }),
res,
)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_9' },
data: expect.objectContaining({ etapa: 'Perdido', motivo: 'Precio' }),
})
expect(tiposEncolados()).toEqual(['DESCARTE'])
expect(json).toHaveBeenCalledWith({ mondayItemId: 'loc_9', etapa: 'Perdido', motivo: 'Precio', motivoItemId: null })
await expect(
cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { etapa: 'Vendido' } }), res),
).rejects.toThrow()
})
})

View File

@@ -1,103 +1,39 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { VOCABULARIO, type MotivoNoCompra } from '../config/vocabulario.js'
import { prisma } from '../services/prisma.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.service.js'
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
export const MOTIVOS_NO_COMPRA = [
'Precio',
'Producto',
'Ubicacion',
'Financiamiento',
'Competencia',
'Cliente',
'Timing',
] as const
export const MOTIVOS_NO_COMPRA = VOCABULARIO.MOTIVOS_NO_COMPRA
/**
* Subcategorías por categoría, tomadas del tablero de referencia "Motivos No
* Compra" del workspace CRM FRASA (los items de cada grupo son el catálogo).
* Sembradas como labels de la columna status 'Subcategoria' del tablero
* Motivos No Compra de Frasa-CAS. NOTA: 'Mesualidad' y 'Cambo de Planes'
* llevan las faltas de ortografía originales del tablero del cliente.
* Subcategorías por categoría, tomadas del catálogo del tablero de referencia
* "Motivos No Compra" del workspace CRM FRASA. NOTA: 'Mesualidad' y 'Cambo de
* Planes' llevan las faltas de ortografía originales del catálogo del cliente.
*/
export const SUBCATEGORIAS_NO_COMPRA: Record<(typeof MOTIVOS_NO_COMPRA)[number], string[]> = {
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
}
export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, readonly string[]> = VOCABULARIO.SUBCATEGORIAS
const perderSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA),
comentario: z.string().optional(),
})
type MondayOportunidades = Pick<
MondayService,
'cambiarValorColumna' | 'crearMotivoNoCompra' | 'obtenerNombreItem'
>
/**
* Marca una oportunidad como perdida: etapa 'Perdido' + dropdown motivo, y
* registra el motivo en el tablero catálogo Motivos No Compra.
* Devuelve el ID del item creado en Motivos No Compra.
* Marca una oportunidad como perdida (etapa 'Perdido' + motivo/submotivo en la
* fila local) y encola el DESCARTE. SIEMPRE local.
*/
export async function perderOportunidad(
monday: MondayOportunidades,
mondayItemId: string,
motivo: string,
comentario?: string,
): Promise<string> {
const boardOportunidades = config.monday.boards.oportunidades
const nombreItem = await monday.obtenerNombreItem(mondayItemId)
await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Estado', 'Perdido')
await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Motivo No Compra', motivo)
return monday.crearMotivoNoCompra({
nombre: `${motivo} — ${nombreItem}`,
categoria: motivo,
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
}
/** POST /api/oportunidades/:mondayItemId/perder { motivo, comentario? } */
export async function perderOportunidadEndpoint(req: Request, res: Response): Promise<void> {
const body = perderSchema.parse(req.body)
const mondayItemId = String(req.params['mondayItemId'])
let motivoItemId: string | null = null
// Modo local (id loc_* o Monday caído / fallback 502): descarte local +
// DESCARTE encolado; el motivo del tablero catálogo lo crea el worker
if (esIdLocal(mondayItemId) || !saludMonday.estaDisponible()) {
await crmLocal.descartar(mondayItemId, body.motivo, {
fuente: 'oportunidad',
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(mondayItemId),
})
} else {
try {
motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.descartar(mondayItemId, body.motivo, {
fuente: 'oportunidad',
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(mondayItemId),
})
}
}
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
await crmLocal.descartar(mondayItemId, body.motivo, {
fuente: 'oportunidad',
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(mondayItemId),
})
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId: null })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'PERDER_OPORTUNIDAD', 'oportunidad', mondayItemId, body.motivo)
}
@@ -105,53 +41,20 @@ export async function perderOportunidadEndpoint(req: Request, res: Response): Pr
/**
* Etapas del funnel que el asesor puede mover desde los botones de
* "Mis oportunidades" (labels REALES de la columna status 'Estado' del board
* Oportunidades — el frontend los manda tal cual).
* "Mis oportunidades" (vocabulario canónico del CRM — el frontend las manda
* tal cual).
*/
export const ETAPAS_OPORTUNIDAD_PERMITIDAS = [
'Agendo Cita',
'Afluencia',
'Cotizador',
'Aparto',
'No Asistio',
'Abandono',
] as const
export const ETAPAS_OPORTUNIDAD_PERMITIDAS = VOCABULARIO.FASES_OPORTUNIDAD
const cambiarEtapaSchema = z.object({
etapa: z.enum(ETAPAS_OPORTUNIDAD_PERMITIDAS),
// En 'Agendo Cita' puede setear la columna date 'Fecha'
// En 'Agendo Cita' puede setear la fecha de la cita
fecha: z.string().optional(),
})
type MondayEtapa = Pick<
MondayService,
'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'
>
/**
* Cambia la etapa de la oportunidad (columna 'Estado'), setea la fecha de la
* cita si aplica y mueve el item al grupo que corresponde a la etapa nueva
* (idempotente; el webhook de change haría lo mismo, pero así la UI responde
* al instante).
*/
export async function cambiarEtapaOportunidad(
monday: MondayEtapa,
itemId: string,
etapa: (typeof ETAPAS_OPORTUNIDAD_PERMITIDAS)[number],
fecha?: string,
): Promise<void> {
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', etapa)
if (etapa === 'Agendo Cita' && fecha !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha)
}
await moverAGrupoSegunEstado(monday, boardOportunidades, itemId)
}
/**
* Cambio de etapa en modo local: guarda la etapa en la fila local y encola
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar),
* el equivalente al asegurarApartado del flujo Monday.
* Cambio de etapa local: guarda la etapa en la fila local y encola
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar).
* Devuelve el unidadCodigo de la oportunidad si existe (para bloquear la unidad).
*/
async function cambiarEtapaLocal(
@@ -185,67 +88,16 @@ async function cambiarEtapaLocal(
export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEtapaSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const usuarioSub = req.usuarioToken?.sub
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub)
// Bloquear la unidad del inventario si la oportunidad tiene una asignada
if (body.etapa === 'Aparto' && unidadCodigo !== null) {
try {
await mondayService.actualizarDisponibilidadUnidad(unidadCodigo, 'APARTADA')
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err)
}
}
} else {
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, req.usuarioToken?.sub)
// Bloquear la unidad del inventario local si la oportunidad tiene una asignada
if (body.etapa === 'Aparto' && unidadCodigo !== null) {
try {
await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
await prisma.unidad.updateMany({ where: { codigo: unidadCodigo }, data: { status: 'APARTADA' } })
} catch (err) {
if (!esFalloMonday(err)) throw err
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub)
// Bloquear la unidad del inventario si la oportunidad tiene una asignada
if (body.etapa === 'Aparto' && unidadCodigo !== null) {
try {
await mondayService.actualizarDisponibilidadUnidad(unidadCodigo, 'APARTADA')
} catch (err2) {
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err2)
}
}
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
return
}
// En 'Aparto' el apartado debe existir YA (Mis apartados recarga al volver):
// asegurarApartado reintenta unos segundos (Monday tarda en propagar la
// etapa) y es idempotente (dedup); el webhook queda como red de seguridad
// para cambios hechos directo en Monday.
if (body.etapa === 'Aparto') {
try {
await automatizacionesService.asegurarApartado(itemId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear el apartado de la oportunidad ${itemId}:`, err)
}
// Bloquear la unidad del inventario (colocar en APARTADA)
try {
const boardOportunidades = config.monday.boards.oportunidades
const svc = mondayService as MondayService
const item = await svc.getItemConGrupo(itemId)
if (item) {
const colUnidad = await svc.columnaIdPorTitulo(boardOportunidades, 'Unidad')
const unidadCodigo = colUnidad
? (item.column_values.find((cv) => cv.id === colUnidad)?.text ?? '').trim()
: ''
if (unidadCodigo !== '') {
await svc.actualizarDisponibilidadUnidad(unidadCodigo, 'APARTADA')
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad de la oportunidad ${itemId}:`, err)
}
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err)
}
}
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
auditoria.registrar(usuarioSub ?? 'sistema', 'CAMBIAR_ETAPA', 'oportunidad', itemId, body.etapa)
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ETAPA', 'oportunidad', itemId, body.etapa)
}

View File

@@ -1,68 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { notificarEscrituracionSiLiquidado, resolverFiltroMios } from './planes.controller.js'
// ---------- Prisma mockeado: el encolado de la réplica no toca la BD ----------
const prismaMock = vi.hoisted(() => ({
syncPendiente: {
create: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
/** Monday mockeado: registra cambios de columna */
function mondayMock() {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
}
}
describe('notificarEscrituracionSiLiquidado (puente cobranza → postventa)', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.syncPendiente.create.mockResolvedValue({})
})
it('plan LIQUIDADO con mondayItemId: mueve el apartado a "X Escriturar"', async () => {
const monday = mondayMock()
const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday)
expect(ok).toBe(true)
// board 666 = MONDAY_BOARD_APARTADO de test
expect(monday.cambios).toEqual([
{ boardId: '666', itemId: 'apt-55', columna: 'Estado', valor: 'X Escriturar' },
])
})
it('no hace nada si el plan no está liquidado o no tiene item ligado', async () => {
const monday = mondayMock()
expect(await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, false, monday)).toBe(false)
expect(await notificarEscrituracionSiLiquidado({ mondayItemId: null }, true, monday)).toBe(false)
expect(await notificarEscrituracionSiLiquidado({}, true, monday)).toBe(false)
expect(monday.cambios).toEqual([])
})
it('un fallo de Monday no rompe el abono: loguea, encola LIQUIDADO_ESCRITURAR y devuelve false', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday)
expect(ok).toBe(false)
expect(spyError).toHaveBeenCalled()
// La escrituración queda encolada para que el worker la aplique al volver Monday
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'LIQUIDADO_ESCRITURAR',
payloadJson: JSON.stringify({ mondayItemId: 'apt-55' }),
referenciaLocal: 'apt-55',
}),
})
spyError.mockRestore()
})
})
import { describe, expect, it } from 'vitest'
import { resolverFiltroMios } from './planes.controller.js'
describe('resolverFiltroMios (?mios=1)', () => {
it('sin ?mios=1 no filtra nada', () => {

View File

@@ -1,7 +1,6 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { prisma } from '../services/prisma.js'
import { encolarSyncSeguro } from '../services/crm-local.service.js'
import {
alCorriente,
avancePlan,
@@ -20,8 +19,6 @@ import {
type PlanPago as PlanPagoDominio,
} from '../services/cobranza.service.js'
import { BadRequestError, NotFoundError } from '../utils/errors.js'
import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client'
// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ----------
@@ -117,7 +114,7 @@ const crearPlanSchema = z.object({
engancheTotal: z.number().positive(),
numPagos: z.number().int().min(1),
tasaMoraMensual: z.number().min(0).optional(),
// Item del tablero Apartado (Monday) ligado al plan; también ligable después con PATCH
// Item del tablero Apartado ligado al plan (compatibilidad de datos)
mondayItemId: z.string().min(1).optional(),
})
@@ -213,31 +210,6 @@ const abonoSchema = z.object({
referencia: z.string().min(1),
})
type MondayApartado = Pick<MondayService, 'cambiarValorColumna'>
/**
* Puente cobranza → postventa: cuando el plan queda LIQUIDADO y está ligado a
* un item del tablero Apartado, ese item pasa a 'X Escriturar'.
* Un fallo de Monday NUNCA rompe el abono: se loguea y devuelve false.
*/
export async function notificarEscrituracionSiLiquidado(
plan: { mondayItemId?: string | null },
liquidado: boolean,
monday: MondayApartado = mondayService,
): Promise<boolean> {
if (!liquidado || plan.mondayItemId == null || plan.mondayItemId === '') return false
try {
await monday.cambiarValorColumna(config.monday.boards.apartado, plan.mondayItemId, 'Estado', 'X Escriturar')
return true
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover el apartado ${plan.mondayItemId} a 'X Escriturar':`, err)
// La escrituración queda encolada (LIQUIDADO_ESCRITURAR) para el worker
await encolarSyncSeguro('LIQUIDADO_ESCRITURAR', { mondayItemId: plan.mondayItemId }, plan.mondayItemId)
return false
}
}
/** POST /api/planes/:id/abonos — aplica un abono (estricto al centavo) */
export async function registrarAbonoEndpoint(req: Request, res: Response): Promise<void> {
const body = abonoSchema.parse(req.body)
@@ -270,24 +242,7 @@ export async function registrarAbonoEndpoint(req: Request, res: Response): Promi
])
const actualizado = await cargarPlan(plan.id)
const dominioActualizado = aDominio(actualizado)
const calculado = conCalculados(dominioActualizado)
// Si el abono liquidó el plan, avisar a postventa (no bloquea la respuesta)
await notificarEscrituracionSiLiquidado(dominioActualizado, calculado.status === 'LIQUIDADO')
res.status(201).json(calculado)
}
const ligarMondaySchema = z.object({ mondayItemId: z.string().min(1).nullable() })
/** PATCH /api/planes/:id/monday — liga (o desliga con null) el item de Apartado en Monday */
export async function ligarMondayItem(req: Request, res: Response): Promise<void> {
const body = ligarMondaySchema.parse(req.body)
const plan = await cargarPlan(String(req.params['id']))
await prisma.planPago.update({ where: { id: plan.id }, data: { mondayItemId: body.mondayItemId } })
const actualizado = await cargarPlan(plan.id)
res.json(conCalculados(aDominio(actualizado)))
res.status(201).json(conCalculados(aDominio(actualizado)))
}
const notaSchema = z.object({ texto: z.string() })

View File

@@ -1,8 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError, NotFoundError } from '../utils/errors.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
@@ -17,23 +16,14 @@ const prismaMock = vi.hoisted(() => ({
},
}))
const mondayMock = vi.hoisted(() => ({
getApartados: vi.fn(),
getContactos: vi.fn(),
getContactoDetalle: vi.fn(),
getOportunidades: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { prefillApartadoEndpoint } from './clientes.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
/**
* Fallback del prefill del alta (GET /api/altas/prefill-apartado/:itemId):
* Monday caído/502 → el prefill se arma desde LeadCaptura (apartado local) +
* Cliente + cotización local, con los campos que el front ya consume.
* Prefill del alta (GET /api/altas/prefill-apartado/:itemId) 100% local:
* el prefill se arma desde LeadCaptura (apartado local) + Cliente + cotización
* local, con los campos que el front ya consume.
*/
const apartadoLocal = {
@@ -101,22 +91,18 @@ function resFake() {
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.leadCaptura.findUnique.mockResolvedValue(apartadoLocal)
prismaMock.leadCaptura.findFirst.mockResolvedValue(oportunidadLocal)
prismaMock.cliente.findFirst.mockResolvedValue(clienteLocal)
prismaMock.cotizacion.findFirst.mockResolvedValue(cotizacionLocal)
})
describe('prefillApartadoEndpoint (fallback local)', () => {
it('Monday caído: prefill desde apartado local + Cliente + oportunidad local + cotización local', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
describe('prefillApartadoEndpoint (GET /api/altas/prefill-apartado/:itemId) — local', () => {
it('prefill desde apartado local + Cliente + oportunidad local + cotización local', async () => {
const { res, json } = resFake()
await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res)
expect(mondayMock.getApartados).not.toHaveBeenCalled()
const prefill = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(prefill['apartado']).toMatchObject({
id: 'loc_a1',
@@ -142,30 +128,23 @@ describe('prefillApartadoEndpoint (fallback local)', () => {
expect(prefill['cotizacion']).toMatchObject({ tipoPropuesta: 'INFONAVIT', acabados: true, precioFinal: 4_400_000 })
})
it('Monday caído y el apartado no existe en local → 404', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
it('el apartado no existe en local → 404', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
await expect(
prefillApartadoEndpoint({ params: { itemId: 'nope' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
).rejects.toMatchObject({ status: 404 })
})
it('Monday lanza 502: cae al prefill local; el 404 de Monday se propaga intacto', async () => {
mondayMock.getApartados.mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res)
expect((json.mock.calls[0]?.[0] as Record<string, unknown>)['apartado']).toMatchObject({ id: 'loc_a1' })
it('un id que no es del board APARTADO → 404 (el prefill solo aplica a apartados)', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ ...apartadoLocal, board: 'LEADS' })
// Apartado inexistente en Monday (404): NO es fallo de Monday, se propaga
mondayMock.getApartados.mockResolvedValue([])
await expect(
prefillApartadoEndpoint({ params: { itemId: 'x' } } as unknown as Request, resFake().res),
).rejects.toThrow(NotFoundError)
prefillApartadoEndpoint({ params: { itemId: 'loc_lead' } } as unknown as Request, resFake().res),
).rejects.toMatchObject({ status: 404 })
})
it('sin Cliente ni oportunidad local: contacto/oportunidad van en null (el front los tolera)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.cotizacion.findFirst.mockResolvedValue(null)

View File

@@ -47,6 +47,7 @@ describe('recordatorio-vendedor.controller', () => {
titulo: 'Test',
descripcion: null,
leadId: null,
clienteClave: null,
fechaRecordar: '2026-09-10T10:00:00',
})
expect(res.status).toHaveBeenCalledWith(201)
@@ -66,6 +67,7 @@ describe('recordatorio-vendedor.controller', () => {
completados: true,
desde: '2026-09-01',
hasta: null,
clienteClave: null,
})
expect(res.json).toHaveBeenCalledWith([])
})

View File

@@ -1,12 +1,11 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { ReportesService } from '../services/reportes.service.js'
import { cargarPlanesDominio } from './planes.controller.js'
import { obtenerScores } from '../services/lead-scoring.service.js'
import { resumenMora } from '../services/mora.service.js'
/** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
export const reportesService = new ReportesService(cargarPlanesDominio)
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta
* ?dias=7|30|90 acota leads/oportunidades al rango (default: todo el histórico) */

View File

@@ -1,15 +1,9 @@
import type { Request, Response } from 'express'
import { prisma } from '../services/prisma.js'
import { saludMonday } from '../services/salud-monday.service.js'
/**
* GET /api/sistema/estado — salud del modo local para el banner del front:
* `mondayOk` (false = operar en modo local) y `pendientesSync` (registros de
* la cola SyncPendiente esperando réplica a Monday, incluidos los en ERROR).
* GET /api/sistema/estado — estado del modo local para el banner del front.
* Monday ya no participa: la API siempre opera con datos locales.
*/
export async function estadoSistema(_req: Request, res: Response): Promise<void> {
const pendientesSync = await prisma.syncPendiente.count({
where: { estado: { in: ['PENDIENTE', 'ERROR'] } },
})
res.json({ mondayOk: saludMonday.estaDisponible(), pendientesSync })
res.json({ mondayOk: false, pendientesSync: 0 })
}

View File

@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { AppError, NotFoundError } from '../utils/errors.js'
import { NotFoundError } from '../utils/errors.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
// ---------- Prisma mockeado (los tests no tocan BD) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
@@ -11,16 +11,9 @@ const prismaMock = vi.hoisted(() => ({
},
}))
const mondayMock = vi.hoisted(() => ({
getUnidades: vi.fn(),
getUnidad: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { listarUnidades, obtenerUnidad } from './unidades.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
const filaLocal = {
codigo: 'U-101',
@@ -42,30 +35,16 @@ function resFake() {
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.unidad.findMany.mockResolvedValue([filaLocal])
prismaMock.unidad.findUnique.mockResolvedValue(filaLocal)
})
describe('unidades.controller (fallback local)', () => {
it('Monday vivo: devuelve Monday y no toca la tabla local', async () => {
mondayMock.getUnidades.mockResolvedValue([{ codigo: 'M-1', status: 'DISPONIBLE', desarrollo: 'SENDA' }])
describe('unidades.controller (local)', () => {
it('lista desde la tabla Unidad con la forma de DTO completa', async () => {
const { res, json } = resFake()
await listarUnidades({ query: {} } as unknown as Request, res)
expect(json).toHaveBeenCalledWith([{ codigo: 'M-1', status: 'DISPONIBLE', desarrollo: 'SENDA' }])
expect(prismaMock.unidad.findMany).not.toHaveBeenCalled()
})
it('Monday caído (circuit breaker): lista desde la tabla Unidad con la misma forma de DTO', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const { res, json } = resFake()
await listarUnidades({ query: {} } as unknown as Request, res)
expect(mondayMock.getUnidades).not.toHaveBeenCalled()
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista).toHaveLength(1)
expect(lista[0]).toMatchObject({
@@ -79,8 +58,7 @@ describe('unidades.controller (fallback local)', () => {
})
})
it('Monday caído: los filtros ?disponibles y ?desarrollo se aplican igual', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
it('los filtros ?disponibles y ?desarrollo se aplican igual', async () => {
prismaMock.unidad.findMany.mockResolvedValue([
filaLocal,
{ ...filaLocal, codigo: 'U-102', status: 'APARTADO' },
@@ -94,18 +72,7 @@ describe('unidades.controller (fallback local)', () => {
expect(lista.map((u) => u['codigo'])).toEqual(['S-1'])
})
it('Monday lanza 502: fallback local; otro error se propaga', async () => {
mondayMock.getUnidades.mockRejectedValue(new AppError(502, 'Monday no respondió'))
const { res, json } = resFake()
await listarUnidades({ query: {} } as unknown as Request, res)
expect((json.mock.calls[0]?.[0] as unknown[]).length).toBe(1)
mondayMock.getUnidades.mockRejectedValue(new AppError(500, 'bug'))
await expect(listarUnidades({ query: {} } as unknown as Request, resFake().res)).rejects.toThrow('bug')
})
it('obtenerUnidad: Monday caído → local; inexistente en ambos → 404', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
it('obtenerUnidad: devuelve el DTO; inexistente → 404', async () => {
const { res, json } = resFake()
await obtenerUnidad({ params: { codigo: 'U-101' } } as unknown as Request, res)

View File

@@ -1,13 +1,13 @@
import type { Request, Response } from 'express'
import { listarUnidadesConFallback, obtenerUnidadConFallback } from '../services/unidades-local.service.js'
import { getUnidadesLocal, getUnidadLocal } from '../services/unidades-local.service.js'
import { NotFoundError } from '../utils/errors.js'
/**
* GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE; ?desarrollo= filtra por desarrollo.
* Monday caído (o 502) → espejo local (tabla Unidad), mismo contrato de respuesta.
* Lee el espejo local (tabla Unidad), mismo contrato de respuesta.
*/
export async function listarUnidades(req: Request, res: Response): Promise<void> {
const unidades = await listarUnidadesConFallback()
const unidades = await getUnidadesLocal()
const disponibles = req.query['disponibles'] === 'true'
const desarrollo = req.query['desarrollo']?.toString().toUpperCase()
let lista = disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades
@@ -17,9 +17,9 @@ export async function listarUnidades(req: Request, res: Response): Promise<void>
res.json(lista)
}
/** GET /api/unidades/:codigo — 404 si no existe ni en Monday ni en el espejo local */
/** GET /api/unidades/:codigo — 404 si no existe en el espejo local */
export async function obtenerUnidad(req: Request, res: Response): Promise<void> {
const unidad = await obtenerUnidadConFallback(String(req.params['codigo']))
const unidad = await getUnidadLocal(String(req.params['codigo']))
if (!unidad) throw new NotFoundError(`Unidad ${String(req.params['codigo'])} no encontrada`)
res.json(unidad)
}

View File

@@ -1,26 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import type { Usuario } from '@prisma/client'
import {
actualizarRolEndpoint,
actualizarVinculoMondayEndpoint,
listarUsuariosMondayEndpoint,
} from './usuarios.controller.js'
import { AppError } from '../utils/errors.js'
import { actualizarRolEndpoint } from './usuarios.controller.js'
// ---------- Monday y Prisma mockeados (los tests no tocan red ni BD) ----------
// ---------- Prisma mockeado (los tests no tocan BD) ----------
const mondayMock = vi.hoisted(() => ({ getUsuariosWorkspace: vi.fn() }))
const prismaMock = vi.hoisted(() => ({ usuario: { update: vi.fn() } }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
const USUARIOS_WORKSPACE = [
{ id: 13676, nombre: 'Erick Peña', email: 'erick@frasa.mx' },
{ id: 20400, nombre: 'Cristina Barrera', email: 'cristina@frasa.mx' },
]
function usuarioFake(overrides: Partial<Usuario> = {}): Usuario {
return {
id: 'u-1',
@@ -54,91 +42,6 @@ function resFake() {
beforeEach(() => {
vi.clearAllMocks()
mondayMock.getUsuariosWorkspace.mockResolvedValue(USUARIOS_WORKSPACE)
})
describe('listarUsuariosMondayEndpoint (GET /api/monday/usuarios)', () => {
it('devuelve los usuarios del workspace con id numérico', async () => {
const { res, json } = resFake()
await listarUsuariosMondayEndpoint(reqFake(), res)
expect(json).toHaveBeenCalledWith([
{ id: 13676, nombre: 'Erick Peña', email: 'erick@frasa.mx' },
{ id: 20400, nombre: 'Cristina Barrera', email: 'cristina@frasa.mx' },
])
})
it('propaga el error si Monday falla (el error handler responde 5xx)', async () => {
mondayMock.getUsuariosWorkspace.mockRejectedValue(new Error('Monday caído'))
const { res } = resFake()
await expect(listarUsuariosMondayEndpoint(reqFake(), res)).rejects.toThrow('Monday caído')
})
})
describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () => {
it('con id válido guarda el vínculo y devuelve el perfil actualizado', async () => {
prismaMock.usuario.update.mockResolvedValue(usuarioFake({ mondayUserId: 13676 }))
const { res, json } = resFake()
await actualizarVinculoMondayEndpoint(reqFake({ body: { mondayUserId: 13676 } }), res)
expect(prismaMock.usuario.update).toHaveBeenCalledWith({
where: { id: 'u-1' },
data: { mondayUserId: 13676 },
})
expect(json).toHaveBeenCalledWith({
nombre: 'ERICK ISRAEL PEÑA RAMOS',
rol: 'vendedor',
asignacion: 'MONTEBELLO',
mondayUserId: 13676,
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
acciones: [],
})
})
it('con id inexistente en el workspace responde 400 y no toca la BD', async () => {
const { res } = resFake()
const error = await actualizarVinculoMondayEndpoint(
reqFake({ body: { mondayUserId: 99999 } }),
res,
).catch((e: unknown) => e)
expect(error).toBeInstanceOf(AppError)
expect(error).toMatchObject({ status: 400 })
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
it('con null desvincula sin consultar Monday', async () => {
prismaMock.usuario.update.mockResolvedValue(usuarioFake({ mondayUserId: null }))
const { res, json } = resFake()
await actualizarVinculoMondayEndpoint(reqFake({ body: { mondayUserId: null } }), res)
expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled()
expect(prismaMock.usuario.update).toHaveBeenCalledWith({
where: { id: 'u-1' },
data: { mondayUserId: null },
})
expect(json).toHaveBeenCalledWith(expect.objectContaining({ mondayUserId: null }))
})
it('sin token verificado: 400', async () => {
const { res } = resFake()
await expect(
actualizarVinculoMondayEndpoint(reqFake({ usuarioToken: undefined }), res),
).rejects.toThrow('Falta el token verificado')
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
it('body inválido (string): ZodError (el error handler lo traduce a 400)', async () => {
const { res } = resFake()
await expect(
actualizarVinculoMondayEndpoint(reqFake({ body: { mondayUserId: '13676' } }), res),
).rejects.toThrow()
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
})
describe('actualizarRolEndpoint (PATCH /api/usuarios/:id/rol)', () => {
@@ -193,4 +96,3 @@ describe('actualizarRolEndpoint (PATCH /api/usuarios/:id/rol)', () => {
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
})

View File

@@ -1,49 +1,10 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { mondayService } from '../services/monday.service.js'
import { prisma } from '../services/prisma.js'
import { aPublico } from '../services/auth.service.js'
import { esRolValido } from '../config/roles.js'
import { AppError, BadRequestError } from '../utils/errors.js'
/**
* Vínculo usuario del ecosistema ↔ usuario de Monday (people). Es el mapeo
* que alimenta round-robin, mis-leads, actividades y alertas; el usuario lo
* elige él mismo desde la landing (Configuración de cuenta).
*/
/** GET /api/monday/usuarios (Bearer) → usuarios activos del workspace */
export async function listarUsuariosMondayEndpoint(_req: Request, res: Response): Promise<void> {
const usuarios = await mondayService.getUsuariosWorkspace()
res.json(usuarios.map((u) => ({ id: u.id, nombre: u.nombre, email: u.email })))
}
const vinculoMondaySchema = z.object({
mondayUserId: z.number().int().positive().nullable(),
})
/**
* PATCH /api/usuarios/me/monday (Bearer) { mondayUserId: number | null }
* null desvincula. Un id que no existe en el workspace → 400.
*/
export async function actualizarVinculoMondayEndpoint(req: Request, res: Response): Promise<void> {
if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado')
const { mondayUserId } = vinculoMondaySchema.parse(req.body)
if (mondayUserId !== null) {
const usuarios = await mondayService.getUsuariosWorkspace()
if (!usuarios.some((u) => u.id === mondayUserId)) {
throw new BadRequestError(`El usuario de Monday ${mondayUserId} no existe en el workspace`)
}
}
const usuario = await prisma.usuario.update({
where: { id: req.usuarioToken.sub },
data: { mondayUserId },
})
res.json(await aPublico(usuario))
}
const cambiarRolSchema = z.object({
rol: z.string().min(1),
})

View File

@@ -1,151 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
const mocks = vi.hoisted(() => ({
runRoundRobin: vi.fn(),
escaneoOportunidades: vi.fn(),
moverAGrupo: vi.fn(),
}))
vi.mock('../services/automatizaciones.service.js', () => ({
automatizacionesService: {
runRoundRobin: mocks.runRoundRobin,
escaneoOportunidades: mocks.escaneoOportunidades,
},
}))
vi.mock('../services/grupos-estado.service.js', () => ({
// Leads (111) y Oportunidades (222) con mapeo; el resto de boards, sin mapeo
GRUPOS_POR_ESTADO: {
'111': { columna: 'Estado', destinos: { NUEVO: 'topics' } },
'222': { columna: 'Estado', destinos: { Ganado: 'group_mm6scqz3' } },
},
moverAGrupoSegunEstado: mocks.moverAGrupo,
}))
vi.mock('../config/index.js', () => ({
config: {
monday: {
boards: { leads: '111', oportunidades: '222' },
},
},
}))
import { webhookMonday } from './webhooks.controller.js'
function resFake(): { res: Response; json: ReturnType<typeof vi.fn>; sendStatus: ReturnType<typeof vi.fn> } {
const json = vi.fn()
const sendStatus = vi.fn()
return { res: { json, sendStatus } as unknown as Response, json, sendStatus }
}
const reqCon = (body: unknown): Request => ({ body } as Request)
beforeEach(() => {
vi.clearAllMocks()
mocks.runRoundRobin.mockResolvedValue({ asignados: 1 })
mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] })
mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' })
})
describe('webhookMonday', () => {
it('responde el challenge del handshake tal cual', () => {
const { res, json, sendStatus } = resFake()
webhookMonday(reqCon({ challenge: 'abc-123' }), res)
expect(json).toHaveBeenCalledWith({ challenge: 'abc-123' })
expect(sendStatus).not.toHaveBeenCalled()
})
it('ignora payloads sin estructura de evento (200 sin disparar nada)', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ foo: 'bar' }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
expect(mocks.escaneoOportunidades).not.toHaveBeenCalled()
})
it('create_item en Oportunidades dispara el round-robin del item (tablero oportunidades)', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999', 'oportunidades')
})
it('create_pulse (nombre real del evento) con pulseId también dispara el round-robin', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_pulse', boardId: 222, pulseId: 777 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('777', 'oportunidades')
})
it('create_item en Leads dispara el round-robin del tablero Leads (SIN crear oportunidad)', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999', 'leads')
})
it('create_item en otro board no dispara round-robin', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 555, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
})
it('change_status_column_value en Oportunidades dispara el escaneo', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'change_status_column_value', boardId: '222', itemId: 1 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
})
it('change_column_value en Oportunidades también dispara el escaneo', () => {
const { res } = resFake()
webhookMonday(reqCon({ event: { type: 'change_column_value', boardId: 222, itemId: 1 } }), res)
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
})
it('update_column_value (nombre real del evento) en Oportunidades dispara el escaneo', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 222, pulseId: 5 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
})
it('cambio de status en otro board no dispara el escaneo de oportunidades', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'change_status_column_value', boardId: 333, itemId: 1 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.escaneoOportunidades).not.toHaveBeenCalled()
})
it('update_column_value en Leads dispara el mover a grupo según estado', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 111, itemId: 999 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.moverAGrupo).toHaveBeenCalledWith(expect.anything(), '111', '999')
})
it('update_column_value en Oportunidades dispara escaneo Y mover a grupo', () => {
const { res } = resFake()
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 222, itemId: 5 } }), res)
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
expect(mocks.moverAGrupo).toHaveBeenCalledWith(expect.anything(), '222', '5')
})
it('update_column_value en un board sin mapeo no dispara el mover a grupo', () => {
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 333, itemId: 1 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
expect(mocks.moverAGrupo).not.toHaveBeenCalled()
})
it('un error del servicio no rompe la respuesta 200 (fire-and-forget)', async () => {
mocks.runRoundRobin.mockRejectedValue(new Error('monday caído'))
const { res, sendStatus } = resFake()
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 1 } }), res)
expect(sendStatus).toHaveBeenCalledWith(200)
// Deja que el rechazo se procese para no ensuciar otros tests
await new Promise((r) => setImmediate(r))
})
})

View File

@@ -1,124 +0,0 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
import { GRUPOS_POR_ESTADO, moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { mondayService } from '../services/monday.service.js'
const eventoSchema = z
.object({
type: z.string(),
boardId: z.union([z.string(), z.number()]),
// Monday manda pulseId en los eventos reales (create_pulse) y itemId en
// las pruebas del panel de webhooks; se aceptan ambos.
itemId: z.union([z.string(), z.number()]).optional(),
pulseId: z.union([z.string(), z.number()]).optional(),
})
.passthrough()
const payloadSchema = z.object({ event: eventoSchema }).passthrough()
/**
* POST /api/webhooks/monday
* Handshake: Monday envía { challenge } al registrar el webhook y exige que
* se responda tal cual. Eventos reales: se responde 200 de inmediato y se
* procesan en segundo plano.
*
* OJO: Monday NO firma sus webhooks → el payload no es confiable. Por eso el
* evento solo se usa como DISPARADOR: el round-robin re-consulta el tablero
* vía API antes de escribir nada.
*/
export function webhookMonday(req: Request, res: Response): void {
const body = req.body as Record<string, unknown>
if (typeof body?.['challenge'] === 'string') {
res.json({ challenge: body['challenge'] })
return
}
const parsed = payloadSchema.safeParse(body)
if (!parsed.success) {
// eslint-disable-next-line no-console
console.log('[webhook monday] payload sin estructura de evento reconocida; se ignora')
res.sendStatus(200)
return
}
const { type, boardId, itemId, pulseId } = parsed.data.event
const esBoardLeads = String(boardId) === config.monday.boards.leads
const esBoardOportunidades = String(boardId) === config.monday.boards.oportunidades
const idEvento = itemId ?? pulseId
// eslint-disable-next-line no-console
console.log(`[webhook monday] tipo=${type} board=${boardId} item=${idEvento ?? '?'}`)
// Monday registra el webhook como 'create_item' pero el payload real llega
// como 'create_pulse'; se aceptan ambos.
// Item nuevo en Leads u Oportunidades → solo round-robin del tablero
// correspondiente (los leads NO crean oportunidad; el Alta Afluencia entra
// directo a Oportunidades vía POST /api/afluencias). El grupo NO se arregla
// aquí: recién creado el item aún no tiene Estado (moverAGrupoSegunEstado no
// haría nada) y el create con column_values no dispara el webhook de change —
// para items creados a mano en la UI, el grupo lo corrige la próxima vez que
// cambie una columna.
if ((type === 'create_item' || type === 'create_pulse') && (esBoardLeads || esBoardOportunidades) && idEvento !== undefined) {
const id = String(idEvento)
const board = esBoardLeads ? 'leads' : 'oportunidades'
// Fire-and-forget: la respuesta 200 no espera a que termine la asignación
void automatizacionesService
.runRoundRobin(id, board)
.then((r) => {
// eslint-disable-next-line no-console
console.log(`[webhook monday] round-robin ${board} item=${id}: ${r.asignados} asignado(s)`)
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error(`[webhook monday] error en round-robin ${board} item=${id}:`, err)
})
}
// Cambio de etapa en Oportunidades → crear Apartado/Venta si aplica.
// El payload de Monday no es confiable (no va firmado), así que solo se usa
// como disparador: el escaneo re-consulta el tablero y es idempotente (dedup
// en AutomatizacionState). El cron de polling queda como red de seguridad.
// OJO: el webhook se registra como 'change_column_value' pero el payload
// real llega como 'update_column_value'; se aceptan los tres nombres.
const esEventoColumna =
type === 'change_status_column_value' || type === 'change_column_value' || type === 'update_column_value'
if (esEventoColumna && esBoardOportunidades) {
void automatizacionesService
.escaneoOportunidades()
.then((r) => {
// eslint-disable-next-line no-console
console.log(
`[webhook monday] oportunidades: ${r.apartadosCreados.length} apartado(s), ` +
`${r.ventasCreadas.length} venta(s), ${r.errores.length} error(es)`,
)
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error('[webhook monday] error en escaneo de oportunidades:', err)
})
}
// Cambio de status en cualquier tablero con mapeo estado → grupo: el item se
// mueve al grupo que corresponde a su nuevo estado (regla de negocio: los
// grupos reflejan la etapa del pipeline). Mismo criterio que arriba: el
// payload solo dispara y el servicio re-consulta el item antes de mover.
// En Oportunidades convive con el escaneo (ambos disparan).
if (esEventoColumna && idEvento !== undefined && GRUPOS_POR_ESTADO[String(boardId)]) {
const board = String(boardId)
const id = String(idEvento)
void moverAGrupoSegunEstado(mondayService, board, id)
.then((r) => {
// eslint-disable-next-line no-console
console.log(`[webhook monday] grupo-estado board=${board} item=${id}: ${r.movido ? `movido a ${r.grupo}` : 'sin cambio'}`)
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error(`[webhook monday] error en grupo-estado board=${board} item=${id}:`, err)
})
}
res.sendStatus(200)
}