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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user