feat(modo-local): fallback completo sin Monday + cola de sincronización
- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes
Env MODO_LOCAL_FORZADO=true para testing sin Monday.
This commit is contained in:
@@ -1,9 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
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) ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
leadCaptura: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
cliente: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
documento: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
syncPendiente: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
/** Monday mockeado: registra cambios de columna y movimientos de grupo */
|
||||
function mondayMock(grupoActual = 'topics') {
|
||||
@@ -71,3 +99,85 @@ describe('cambiarEstadoApartado (POST /api/apartados/:id/estado)', () => {
|
||||
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Modo local (saludMonday caído / ids loc_*) ----------
|
||||
|
||||
describe('ruteo modo local (apartados)', () => {
|
||||
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()
|
||||
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))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
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
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_apt', board: 'APARTADO' })
|
||||
const { res, json } = resFake()
|
||||
|
||||
await cambiarEstadoApartadoEndpoint(
|
||||
{ params: { itemId: 'loc_apt' }, body: { estado: 'Alta Enkontrol' } } as unknown as Request,
|
||||
res,
|
||||
)
|
||||
|
||||
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
|
||||
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)
|
||||
// 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 = {
|
||||
originalname: 'curp.pdf',
|
||||
path: '/tmp/uploads/apartados/loc_apt/CURP_1.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
size: 1234,
|
||||
} as Express.Multer.File
|
||||
const { res, status } = resFake()
|
||||
|
||||
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',
|
||||
}),
|
||||
})
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,9 @@ import { config } from '../config/index.js'
|
||||
import { mondayService, type MondayService } from '../services/monday.service.js'
|
||||
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.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'
|
||||
|
||||
/**
|
||||
@@ -54,7 +57,24 @@ export async function cambiarEstadoApartado(
|
||||
export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = cambiarEstadoSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
await cambiarEstadoApartado(mondayService, itemId, body.estado)
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
res.json({ itemId, estado: body.estado })
|
||||
}
|
||||
|
||||
@@ -104,12 +124,28 @@ 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.
|
||||
* 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.
|
||||
*/
|
||||
async function clienteDeApartado(itemId: string, capturadoPorId?: string) {
|
||||
const existente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } })
|
||||
if (existente) return existente
|
||||
const nombre = await mondayService.obtenerNombreItem(itemId)
|
||||
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'
|
||||
}
|
||||
return prisma.cliente.create({
|
||||
data: {
|
||||
nombreCompleto: nombre,
|
||||
@@ -151,14 +187,20 @@ 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.
|
||||
// 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) {
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -17,6 +17,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
usuario: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
syncPendiente: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mondayMock = vi.hoisted(() => ({
|
||||
@@ -276,3 +279,98 @@ describe('crearCliente — Apartado con ficha completa', () => {
|
||||
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' },
|
||||
})
|
||||
})
|
||||
|
||||
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'))
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearCliente(reqFake({ ...altaConVenta, leadMondayId: 'lead-9' }), 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',
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,13 @@ import { randomUUID } from 'node:crypto'
|
||||
import multer from 'multer'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config/index.js'
|
||||
import { mondayService, type DatosContacto } from '../services/monday.service.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 { NotFoundError, BadRequestError } from '../utils/errors.js'
|
||||
|
||||
// ---------- Alta de clientes ----------
|
||||
@@ -177,6 +180,8 @@ async function moverLeadAApartoSiAplica(body: Record<string, unknown>): Promise<
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,8 +212,12 @@ export function formaPagoLabel(tiposCredito: unknown): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Item en el tablero Apartado (pipeline Enkontrol) con la ficha completa; solo si hay valor de venta */
|
||||
async function crearApartadoSiAplica(
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
async function datosApartadoDeAlta(
|
||||
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
|
||||
vc: {
|
||||
ubicacion?: string | undefined
|
||||
@@ -217,7 +226,7 @@ async function crearApartadoSiAplica(
|
||||
tiposCredito?: unknown
|
||||
},
|
||||
capturadoPorId: string | null,
|
||||
): Promise<string | null> {
|
||||
): Promise<DatosApartado | null> {
|
||||
const valorVenta = aNumero(vc.valorVenta)
|
||||
if (valorVenta <= 0) return null
|
||||
const enganche = aNumero(vc.enganche)
|
||||
@@ -230,7 +239,7 @@ async function crearApartadoSiAplica(
|
||||
})
|
||||
vendedorId = capturista?.mondayUserId ?? undefined
|
||||
}
|
||||
return mondayService.crearApartado({
|
||||
return {
|
||||
clienteNombre: dg.nombreCompleto,
|
||||
unidadCodigo: vc.ubicacion ?? '',
|
||||
...(enganche > 0 ? { montoApartado: enganche } : {}),
|
||||
@@ -245,7 +254,59 @@ async function crearApartadoSiAplica(
|
||||
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.
|
||||
*/
|
||||
async function encolarReplicasPendientesCliente(
|
||||
clienteId: string,
|
||||
datosContacto: DatosContacto,
|
||||
mondayContactoId: string | null,
|
||||
mondayApartadoId: string | null,
|
||||
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> {
|
||||
if (mondayContactoId === null) {
|
||||
await encolarSyncSeguro('CREAR_CONTACTO', { datos: datosContacto }, clienteId)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,21 +369,22 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||
// 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({
|
||||
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) },
|
||||
})
|
||||
mondayContactoId = await mondayService.crearContacto(datosContacto)
|
||||
|
||||
mondayApartadoId = await apartadoDeAlta(dg, vc, req.usuarioToken?.sub ?? null, body.apartadoMondayId)
|
||||
|
||||
@@ -333,6 +395,28 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
|
||||
@@ -440,17 +524,20 @@ export async function iniciarProgreso(req: Request, res: Response): Promise<void
|
||||
},
|
||||
})
|
||||
|
||||
// Contacto PROSPECTO en Monday; si falla, el progreso local ya quedó
|
||||
// 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({
|
||||
...contactoDesdeDatos(body as Record<string, unknown>),
|
||||
estadoLabel: 'PROSPECTO',
|
||||
})
|
||||
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 })
|
||||
@@ -466,6 +553,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 {
|
||||
@@ -474,6 +562,7 @@ export async function actualizarProgreso(req: Request, res: Response): Promise<v
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,17 +602,15 @@ export async function completarProgreso(req: Request, res: Response): Promise<vo
|
||||
// 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, {
|
||||
...contactoDesdeDatos(body as Record<string, unknown>),
|
||||
estadoLabel: 'CLIENTE',
|
||||
})
|
||||
await mondayService.actualizarContacto(mondayContactoId, datosContactoCliente)
|
||||
} else {
|
||||
mondayContactoId = await mondayService.crearContacto({
|
||||
...contactoDesdeDatos(body as Record<string, unknown>),
|
||||
estadoLabel: 'CLIENTE',
|
||||
})
|
||||
mondayContactoId = await mondayService.crearContacto(datosContactoCliente)
|
||||
}
|
||||
|
||||
if (mondayApartadoId === null) {
|
||||
@@ -540,6 +627,32 @@ export async function completarProgreso(req: Request, res: Response): Promise<vo
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({ ...actualizado, mondayContactoId, mondayApartadoId })
|
||||
@@ -614,19 +727,25 @@ 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.
|
||||
// (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) {
|
||||
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)
|
||||
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)
|
||||
@@ -654,31 +773,141 @@ export async function listarDocumentos(req: Request, res: Response): Promise<voi
|
||||
*/
|
||||
export async function prefillApartadoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const itemId = String(req.params['itemId'])
|
||||
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' },
|
||||
})
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape de la cotización en la respuesta del prefill (compartido Monday/local) */
|
||||
function cotizacionPrefill(cotizacion: {
|
||||
tipoPropuesta: string
|
||||
acabados: boolean
|
||||
apartado: number
|
||||
precioLista: number
|
||||
precioFinal: number
|
||||
enganche: number
|
||||
numMensualidades: number
|
||||
mensualidad: number | null
|
||||
pagoFinal: number
|
||||
}) {
|
||||
return {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function prefillApartadoLocal(itemId: string) {
|
||||
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
||||
if (!captura || captura.board !== 'APARTADO') throw new NotFoundError('Apartado no encontrado')
|
||||
|
||||
const tel = (captura.telefono ?? '').replace(/\D/g, '').slice(-10)
|
||||
const cliente = await prisma.cliente.findFirst({
|
||||
where: {
|
||||
OR: [{ mondayApartadoId: itemId }, ...(tel !== '' ? [{ celular: { contains: tel } }] : [])],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const oportunidadLocal = await prisma.leadCaptura.findFirst({
|
||||
where: {
|
||||
board: 'OPORTUNIDADES',
|
||||
...(tel !== '' ? { telefono: { contains: tel } } : { nombreLead: captura.nombreLead }),
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
// Perfil de la afluencia (datosJson): solo los campos que el front consume
|
||||
let credito = ''
|
||||
if (oportunidadLocal?.datosJson) {
|
||||
try {
|
||||
const datos = JSON.parse(oportunidadLocal.datosJson) as Record<string, unknown>
|
||||
if (typeof datos['credito'] === 'string') credito = datos['credito']
|
||||
} catch {
|
||||
// datosJson inválido: se ignora, el prefill sale igual
|
||||
}
|
||||
}
|
||||
|
||||
// Cotización local más reciente: vinculada a la oportunidad local o por teléfono
|
||||
let cotizacion = oportunidadLocal
|
||||
? await prisma.cotizacion.findFirst({
|
||||
where: { mondayItemId: oportunidadLocal.leadId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: null
|
||||
if (!cotizacion && tel !== '') {
|
||||
cotizacion = await prisma.cotizacion.findFirst({
|
||||
where: { clienteTelefono: { contains: tel } },
|
||||
@@ -686,22 +915,36 @@ export async function prefillApartadoEndpoint(req: Request, res: Response): Prom
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
apartado,
|
||||
contacto,
|
||||
oportunidad,
|
||||
cotizacion: cotizacion
|
||||
return {
|
||||
apartado: {
|
||||
id: captura.leadId,
|
||||
nombre: captura.nombreLead,
|
||||
unidadCodigo: cliente?.unidadCodigo ?? cotizacion?.unidadCodigo ?? '',
|
||||
fechaLimite: '',
|
||||
estado: captura.etapa,
|
||||
vendedorIds: [] as number[],
|
||||
telefono: captura.telefono ?? '',
|
||||
email: captura.email ?? '',
|
||||
precioFinal: cotizacion?.precioFinal ?? 0,
|
||||
enganche: cotizacion?.enganche ?? 0,
|
||||
},
|
||||
contacto: cliente
|
||||
? {
|
||||
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,
|
||||
nombre: cliente.nombreCompleto,
|
||||
Teléfono: cliente.celular ?? '',
|
||||
Email: cliente.email ?? '',
|
||||
CURP: cliente.curp,
|
||||
RFC: cliente.rfc,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
oportunidad: oportunidadLocal
|
||||
? {
|
||||
id: oportunidadLocal.leadId,
|
||||
desarrollo: oportunidadLocal.desarrollo ?? '',
|
||||
modeloInteres: oportunidadLocal.modelo ?? '',
|
||||
credito,
|
||||
}
|
||||
: null,
|
||||
cotizacion: cotizacion ? cotizacionPrefill(cotizacion) : null,
|
||||
}
|
||||
}
|
||||
|
||||
138
api-gateway/src/controllers/contactos-fallback.test.ts
Normal file
138
api-gateway/src/controllers/contactos-fallback.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
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', Teléfono: '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,15 +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 } from '../services/contactos-local.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.
|
||||
*/
|
||||
export async function listarContactosEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await mondayService.getContactos())
|
||||
if (!saludMonday.estaDisponible()) {
|
||||
res.json(await listarContactosLocal())
|
||||
return
|
||||
}
|
||||
try {
|
||||
res.json(await mondayService.getContactos())
|
||||
} catch (err) {
|
||||
if (!esFalloMonday(err)) throw err
|
||||
res.json(await listarContactosLocal())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,74 +34,91 @@ export async function listarContactosEndpoint(_req: Request, res: Response): Pro
|
||||
export async function detalleContactoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const contactoId = String(req.params['id'])
|
||||
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
try {
|
||||
const contacto = await mondayService.getContactoDetalle(contactoId)
|
||||
if (contacto === null) throw new NotFoundError('Contacto no encontrado')
|
||||
|
||||
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,
|
||||
// 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)
|
||||
}
|
||||
: null
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
|
||||
}
|
||||
|
||||
res.json(await detalleContacto(mondayService, contactoId, nombresPorMondayId, capturas, clienteLocal))
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
114
api-gateway/src/controllers/cotizaciones-fallback.test.ts
Normal file
114
api-gateway/src/controllers/cotizaciones-fallback.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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 }))
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
syncPendiente: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mondayMock = vi.hoisted(() => ({
|
||||
@@ -103,13 +106,38 @@ describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', ()
|
||||
expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('si Monday falla, la cotización igual se crea (201 con mondayItemId null)', async () => {
|
||||
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({})
|
||||
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 () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
type ResultadoAlertas,
|
||||
} from '../services/alertas-inventario.service.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { encolarSyncSeguro } from '../services/crm-local.service.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
|
||||
|
||||
@@ -50,7 +52,8 @@ export const LABEL_PROPUESTA_MONDAY: Record<string, string> = {
|
||||
export async function crearCotizacion(req: Request, res: Response): Promise<void> {
|
||||
const body = crearCotizacionSchema.parse(req.body)
|
||||
|
||||
const unidad = await mondayService.getUnidad(body.unidadCodigo)
|
||||
// Monday caído (o 502) → espejo local (tabla Unidad); 404 solo si no existe en ninguno
|
||||
const unidad = await obtenerUnidadConFallback(body.unidadCodigo)
|
||||
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
|
||||
|
||||
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
|
||||
@@ -78,21 +81,23 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
},
|
||||
})
|
||||
|
||||
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó persistida
|
||||
// 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
|
||||
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 {
|
||||
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 } : {}),
|
||||
}
|
||||
// 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
|
||||
@@ -113,6 +118,15 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
} 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,
|
||||
)
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
import {
|
||||
buscarLeads,
|
||||
buscarLeadsEndpoint,
|
||||
cambiarEstadoLead,
|
||||
cambiarEstadoLeadEndpoint,
|
||||
convertirLead,
|
||||
convertirLeadEndpoint,
|
||||
crearAfluencia,
|
||||
crearAfluenciaEndpoint,
|
||||
crearLead,
|
||||
crearLeadEndpoint,
|
||||
descartarLead,
|
||||
descartarLeadEndpoint,
|
||||
ESTADOS_LEAD_PERMITIDOS,
|
||||
} from './leads.controller.js'
|
||||
import { mondayService, type LeadMonday, type OportunidadMonday } from '../services/monday.service.js'
|
||||
import { automatizacionesService } from '../services/automatizaciones.service.js'
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
leadCaptura: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
leadOportunidad: {
|
||||
create: vi.fn(),
|
||||
@@ -32,6 +41,13 @@ const prismaMock = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
syncPendiente: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
cliente: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
@@ -787,3 +803,217 @@ describe('convertirLead (POST /api/leads/:itemId/convertir) — botón "Agendo"
|
||||
expect(monday.oportunidadesCreadas[0]?.['origenLabel']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Modo local (saludMonday caído / ids loc_* / fallback 502) ----------
|
||||
|
||||
describe('ruteo modo local (Monday caído o fallback 502)', () => {
|
||||
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.leadCaptura.findFirst.mockResolvedValue(null) // sin duplicados locales
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
|
||||
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
|
||||
prismaMock.leadOportunidad.create.mockResolvedValue({})
|
||||
prismaMock.syncPendiente.create.mockResolvedValue({})
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('crearLeadEndpoint con Monday caído: captura local 201 (id loc_*) y encola CREAR_LEAD sin tocar Monday', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
|
||||
const spyCrear = vi.spyOn(mondayService, 'crearLead')
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqCon({ body: { nombre: 'Lead Local', telefono: '4771234567' } }), res)
|
||||
|
||||
expect(spyCrear).not.toHaveBeenCalled()
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
const cuerpo = json.mock.calls[0]?.[0] as { id: string; asignacion: string }
|
||||
expect(cuerpo.id).toMatch(/^loc_/)
|
||||
expect(cuerpo).toMatchObject({ estado: 'NUEVO', oportunidadId: null, asignacion: 'capturista' })
|
||||
expect(tiposEncolados()).toEqual(['CREAR_LEAD', 'ASIGNAR_PERSONA'])
|
||||
// La fila local quedó board=LEADS etapa NUEVO pendiente de sincronizar
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }),
|
||||
})
|
||||
})
|
||||
|
||||
it('crearLeadEndpoint con Monday vivo que lanza 502: fallback a captura local (misma forma de respuesta)', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
|
||||
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockRejectedValue(new AppError(502, 'Monday no respondió'))
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearLeadEndpoint(reqCon({ body: { nombre: 'Lead Fallback', telefono: '4771234567' } }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect((json.mock.calls[0]?.[0] as { id: string }).id).toMatch(/^loc_/)
|
||||
expect(tiposEncolados()).toContain('CREAR_LEAD')
|
||||
})
|
||||
|
||||
it('crearLeadEndpoint con error NO-502 de Monday: se propaga (no hay fallback)', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
|
||||
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockRejectedValue(new AppError(400, 'Origen inválido'))
|
||||
await expect(
|
||||
crearLeadEndpoint(reqCon({ body: { nombre: 'X', telefono: '4771234567' } }), resFake().res),
|
||||
).rejects.toMatchObject({ status: 400 })
|
||||
})
|
||||
|
||||
it('crearAfluenciaEndpoint con Monday caído: afluencia local 201 y encola CREAR_AFLUENCIA', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
|
||||
const spyCrear = vi.spyOn(mondayService, 'crearOportunidadAfluencia')
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearAfluenciaEndpoint(reqCon({ body: { nombre: 'Afluencia Local', telefono: '6641112233', modelo: 'SOHO' } }), res)
|
||||
|
||||
expect(spyCrear).not.toHaveBeenCalled()
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
const cuerpo = json.mock.calls[0]?.[0] as { id: string; oportunidadId: string }
|
||||
expect(cuerpo.id).toMatch(/^loc_/)
|
||||
expect(cuerpo.oportunidadId).toBe(cuerpo.id)
|
||||
expect(tiposEncolados()).toEqual(['CREAR_AFLUENCIA', 'ASIGNAR_PERSONA'])
|
||||
// Auto-mapping local leadId = oportunidadId
|
||||
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({
|
||||
data: { leadId: cuerpo.id, oportunidadId: cuerpo.id },
|
||||
})
|
||||
})
|
||||
|
||||
it('cambiarEstadoLeadEndpoint con id loc_* siempre va a local (aunque Monday esté vivo)', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
|
||||
const spyCambiar = vi.spyOn(mondayService, 'cambiarValorColumna')
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'LEADS' })
|
||||
const { res, json } = resFake()
|
||||
|
||||
await cambiarEstadoLeadEndpoint(
|
||||
reqCon({ params: { itemId: 'loc_1' }, body: { estado: 'CONTACTADO', fuente: 'lead' } }),
|
||||
res,
|
||||
)
|
||||
|
||||
expect(spyCambiar).not.toHaveBeenCalled()
|
||||
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
|
||||
where: { leadId: 'loc_1' },
|
||||
data: { etapa: 'Contactado', pendienteSync: true },
|
||||
})
|
||||
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
|
||||
expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', estado: 'CONTACTADO' })
|
||||
})
|
||||
|
||||
it('cambiarEstadoLeadEndpoint con id Monday y 502: crea fila sombra y encola CAMBIO_ETAPA', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
|
||||
vi.spyOn(mondayService, 'cambiarValorColumna').mockRejectedValue(new AppError(502, 'Monday no respondió'))
|
||||
const { res, json } = resFake()
|
||||
|
||||
await cambiarEstadoLeadEndpoint(
|
||||
reqCon({ params: { itemId: '99887766' }, body: { estado: 'CONTACTADO', fuente: 'lead' } }),
|
||||
res,
|
||||
)
|
||||
|
||||
// Sombra: el item Monday viejo queda reflejado localmente (Mi día) con su id como mondayItemId
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ leadId: '99887766', board: 'LEADS', mondayItemId: '99887766' }),
|
||||
})
|
||||
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
|
||||
expect(json).toHaveBeenCalledWith({ itemId: '99887766', estado: 'CONTACTADO' })
|
||||
})
|
||||
|
||||
it('descartarLeadEndpoint con id loc_*: descarte local, motivoItemId null y DESCARTE encolado', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_2', nombreLead: 'Lead Local' })
|
||||
const { res, json } = resFake()
|
||||
|
||||
await descartarLeadEndpoint(
|
||||
reqCon({ params: { itemId: 'loc_2' }, body: { motivo: 'Precio', subcategoria: 'Enganche' } }),
|
||||
res,
|
||||
)
|
||||
|
||||
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
|
||||
where: { leadId: 'loc_2' },
|
||||
data: expect.objectContaining({ etapa: 'Perdido', motivo: 'Precio', submotivo: 'Enganche' }),
|
||||
})
|
||||
expect(tiposEncolados()).toEqual(['DESCARTE'])
|
||||
expect(json).toHaveBeenCalledWith({ itemId: 'loc_2', estado: 'Perdido', motivo: 'Precio', motivoItemId: null })
|
||||
})
|
||||
|
||||
it('convertirLeadEndpoint con id loc_*: conversión local 201 y CONVERSION encolada', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(true)
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'loc_lead',
|
||||
oportunidadId: null,
|
||||
usuarioId: 'u-1',
|
||||
nombreLead: 'Lead Local',
|
||||
telefono: '4771234567',
|
||||
email: null,
|
||||
origen: null,
|
||||
desarrollo: null,
|
||||
modelo: null,
|
||||
datosJson: null,
|
||||
})
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await convertirLeadEndpoint(reqCon({ params: { itemId: 'loc_lead' }, body: {} }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
const cuerpo = json.mock.calls[0]?.[0] as { leadId: string; oportunidadId: string; etapa: string }
|
||||
expect(cuerpo.leadId).toBe('loc_lead')
|
||||
expect(cuerpo.oportunidadId).toMatch(/^loc_/)
|
||||
expect(cuerpo.etapa).toBe('Agendo Cita')
|
||||
expect(tiposEncolados()).toEqual(['CONVERSION'])
|
||||
})
|
||||
|
||||
it('convertirLeadEndpoint local con lead ya convertido → 409', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_lead', oportunidadId: 'loc_op_previa' })
|
||||
await expect(
|
||||
convertirLeadEndpoint(reqCon({ params: { itemId: 'loc_lead' }, body: {} }), resFake().res),
|
||||
).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
|
||||
it('buscarLeadsEndpoint con Monday caído busca en local (LeadCaptura + Cliente)', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
|
||||
const spyLeads = vi.spyOn(mondayService, 'getLeads')
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
{
|
||||
leadId: 'loc_1',
|
||||
nombreLead: 'Ana Local',
|
||||
telefono: '4771234567',
|
||||
email: null,
|
||||
etapa: 'Afluencia',
|
||||
origen: 'WhatsApp',
|
||||
board: 'OPORTUNIDADES',
|
||||
desarrollo: 'SENDA',
|
||||
modelo: null,
|
||||
updatedAt: new Date('2026-09-05T10:00:00Z'),
|
||||
},
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await buscarLeadsEndpoint(reqCon({ query: { q: 'ana' } }), res)
|
||||
|
||||
expect(spyLeads).not.toHaveBeenCalled()
|
||||
expect(json).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: 'loc_1', nombre: 'Ana Local', fuente: 'oportunidad', estado: 'Afluencia' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,9 @@ import { mondayService, type LeadMonday, type MondayService, type OportunidadMon
|
||||
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
|
||||
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { 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 { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
@@ -402,7 +405,8 @@ export async function descartarLead(
|
||||
/**
|
||||
* 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).
|
||||
* 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(
|
||||
@@ -424,6 +428,8 @@ async function asignarCapturistaSiAplica(
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -437,7 +443,23 @@ async function asignarCapturistaSiAplica(
|
||||
/** 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 resultado = await crearLead(mondayService, body)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if ('duplicado' in resultado) {
|
||||
res.status(409).json({
|
||||
error: 'Ya existe un lead con ese teléfono',
|
||||
@@ -446,16 +468,27 @@ 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 (req.usuarioToken?.sub) {
|
||||
if (usuarioSub) {
|
||||
try {
|
||||
await prisma.leadCaptura.create({
|
||||
data: {
|
||||
leadId: resultado.id,
|
||||
oportunidadId: null, // los leads ya no generan oportunidad
|
||||
usuarioId: req.usuarioToken.sub,
|
||||
usuarioId: usuarioSub,
|
||||
nombreLead: body.nombre,
|
||||
telefono: body.telefono ?? null,
|
||||
},
|
||||
@@ -465,14 +498,29 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
|
||||
}
|
||||
}
|
||||
const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'leads')
|
||||
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'leads')
|
||||
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion })
|
||||
}
|
||||
|
||||
/** 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 resultado = await crearAfluencia(mondayService, body)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if ('duplicado' in resultado) {
|
||||
res.status(409).json({
|
||||
error: 'Ya existe un lead con ese teléfono',
|
||||
@@ -487,16 +535,25 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
|
||||
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 (req.usuarioToken?.sub) {
|
||||
if (usuarioSub) {
|
||||
try {
|
||||
await prisma.leadCaptura.create({
|
||||
data: {
|
||||
leadId: resultado.id,
|
||||
oportunidadId: resultado.oportunidadId,
|
||||
usuarioId: req.usuarioToken.sub,
|
||||
usuarioId: usuarioSub,
|
||||
nombreLead: body.nombre,
|
||||
telefono: body.telefono ?? null,
|
||||
},
|
||||
@@ -517,15 +574,51 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err)
|
||||
}
|
||||
const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'oportunidades')
|
||||
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'oportunidades')
|
||||
res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function cambiarEstadoLocal(
|
||||
itemId: string,
|
||||
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
|
||||
fecha: string | undefined,
|
||||
fuente: FuenteLead,
|
||||
usuarioId: string | undefined,
|
||||
): Promise<void> {
|
||||
const esLead = fuente === 'lead'
|
||||
if (estado === 'INTERESADO' && !esLead) {
|
||||
throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads')
|
||||
}
|
||||
const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado]
|
||||
await crmLocal.cambiarEstado(itemId, label, esLead ? 'LEADS' : 'OPORTUNIDADES', {
|
||||
fecha,
|
||||
usuarioId,
|
||||
sombra: !esIdLocal(itemId),
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /api/leads/:itemId/estado { estado, fecha?, fuente? } */
|
||||
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = cambiarEstadoSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente)
|
||||
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)
|
||||
}
|
||||
}
|
||||
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
|
||||
}
|
||||
|
||||
@@ -533,7 +626,30 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr
|
||||
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = descartarSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria)
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -618,18 +734,39 @@ export async function convertirLead(
|
||||
export async function convertirLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = convertirSchema.parse(req.body ?? {})
|
||||
const itemId = String(req.params['itemId'])
|
||||
const { oportunidadId } = await convertirLead(mondayService, itemId, req.usuarioToken?.sub, body.fecha)
|
||||
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
|
||||
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' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { oportunidadId } = await convertirLead(mondayService, itemId, usuarioSub, body.fecha)
|
||||
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
|
||||
} 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' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
|
||||
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); 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))
|
||||
}
|
||||
|
||||
172
api-gateway/src/controllers/mi-dia.controller.test.ts
Normal file
172
api-gateway/src/controllers/mi-dia.controller.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
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) ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
usuario: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
leadCaptura: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
leadOportunidad: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
cliente: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
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'
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
|
||||
const usuarioFake = {
|
||||
id: 'u-1',
|
||||
nombre: 'Martin Zavala',
|
||||
rol: 'vendedor',
|
||||
activo: true,
|
||||
mondayUserId: 123,
|
||||
}
|
||||
|
||||
function capturaFake(parcial: Record<string, unknown> & { leadId: string }) {
|
||||
return {
|
||||
id: `row-${parcial.leadId}`,
|
||||
nombreLead: `Persona ${parcial.leadId}`,
|
||||
telefono: null,
|
||||
email: null,
|
||||
origen: null,
|
||||
desarrollo: null,
|
||||
modelo: null,
|
||||
board: 'LEADS',
|
||||
etapa: 'NUEVO',
|
||||
motivo: null,
|
||||
submotivo: null,
|
||||
datosJson: null,
|
||||
mondayItemId: null,
|
||||
pendienteSync: false,
|
||||
usuarioId: 'u-1',
|
||||
createdAt: new Date('2026-09-03T10:00:00'),
|
||||
updatedAt: new Date('2026-09-04T10:00:00'),
|
||||
...parcial,
|
||||
}
|
||||
}
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
const status = vi.fn().mockReturnValue({ json })
|
||||
return { res: { json, status } as unknown as Response, status, json }
|
||||
}
|
||||
|
||||
const reqFake = { usuarioToken: { sub: 'u-1' } } as unknown as Request
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
|
||||
saludMonday.restablecer()
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake)
|
||||
prismaMock.usuario.findMany.mockResolvedValue([])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
prismaMock.leadOportunidad.findMany.mockResolvedValue([])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
|
||||
mondayMock.getLeads.mockResolvedValue([])
|
||||
mondayMock.getOportunidades.mockResolvedValue([])
|
||||
mondayMock.getActividades.mockResolvedValue([])
|
||||
mondayMock.getApartados.mockResolvedValue([])
|
||||
mondayMock.getUltimosCambios.mockResolvedValue(new Map())
|
||||
})
|
||||
|
||||
describe('resumenMiDiaEndpoint (modo local y unión)', () => {
|
||||
it('Monday caído (circuit breaker): resumen 100% local, sin llamadas a Monday', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }),
|
||||
capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }),
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await resumenMiDiaEndpoint(reqFake, res)
|
||||
|
||||
expect(mondayMock.getLeads).not.toHaveBeenCalled()
|
||||
expect(mondayMock.getOportunidades).not.toHaveBeenCalled()
|
||||
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect((resumen['misLeads'] as unknown[]).length).toBe(2)
|
||||
expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1)
|
||||
expect(resumen['misActividadesHoy']).toEqual([])
|
||||
expect(resumen['conteos']).toMatchObject({ actividadesAbiertas: 0 })
|
||||
})
|
||||
|
||||
it('Monday se cae a media lectura (502): cae al resumen local', async () => {
|
||||
mondayMock.getLeads.mockRejectedValue(new AppError(502, 'Monday no respondió'))
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
capturaFake({ leadId: 'loc_l1', board: 'LEADS', pendienteSync: true }),
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await resumenMiDiaEndpoint(reqFake, res)
|
||||
|
||||
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect((resumen['misLeads'] as { id: string }[]).map((l) => l.id)).toEqual(['loc_l1'])
|
||||
})
|
||||
|
||||
it('Monday vivo: unión — las capturas pendientes se agregan con pendienteSync (badge ⏳)', async () => {
|
||||
mondayMock.getLeads.mockResolvedValue([
|
||||
{
|
||||
id: '9001',
|
||||
nombre: 'Lead Monday',
|
||||
createdAt: '2026-09-01T10:00:00.000Z',
|
||||
updatedAt: '2026-09-02T10:00:00.000Z',
|
||||
estado: 'Nuevo',
|
||||
origen: 'Web',
|
||||
telefono: '',
|
||||
email: '',
|
||||
vendedorIds: [123],
|
||||
},
|
||||
])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
capturaFake({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true, updatedAt: new Date('2026-09-05T10:00:00') }),
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await resumenMiDiaEndpoint(reqFake, res)
|
||||
|
||||
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
const leads = resumen['misLeads'] as { id: string; pendienteSync?: boolean }[]
|
||||
expect(leads.map((l) => l.id)).toEqual(['loc_n1', '9001']) // el local, más reciente, primero
|
||||
expect(leads[0]?.pendienteSync).toBe(true)
|
||||
expect(leads[1]?.pendienteSync).toBeUndefined() // el de Monday no lleva flag
|
||||
// Y la captura quedó marcada en Mis capturas
|
||||
const capturas = resumen['misCapturas'] as { leadId: string; pendienteSync?: boolean; estado: string }[]
|
||||
expect(capturas.find((c) => c.leadId === 'loc_n1')).toMatchObject({ pendienteSync: true, estado: 'NUEVO' })
|
||||
})
|
||||
|
||||
it('Monday vivo y sin pendientes: respuesta idéntica a la de hoy (sin flags)', async () => {
|
||||
const { res, json } = resFake()
|
||||
|
||||
await resumenMiDiaEndpoint(reqFake, res)
|
||||
|
||||
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(resumen['misLeads']).toEqual([])
|
||||
expect(JSON.stringify(resumen)).not.toContain('pendienteSync')
|
||||
})
|
||||
|
||||
it('un error no-Monday se propaga (no se traga nada)', async () => {
|
||||
mondayMock.getLeads.mockRejectedValue(new AppError(500, 'bug'))
|
||||
await expect(resumenMiDiaEndpoint(reqFake, resFake().res)).rejects.toThrow('bug')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { resumenMiDia } from '../services/mi-dia.service.js'
|
||||
import { resumenMiDia, resumenMiDiaLocal, unirPendientesLocales } from '../services/mi-dia.service.js'
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
import { esFalloMonday } from '../services/sync-monday.service.js'
|
||||
import { config } from '../config/index.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
@@ -62,16 +64,30 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
res.json(
|
||||
await resumenMiDia(
|
||||
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId }
|
||||
|
||||
// Monday caído (circuit breaker / MODO_LOCAL_FORZADO): todo desde local
|
||||
if (!saludMonday.estaDisponible()) {
|
||||
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const resumen = await resumenMiDia(
|
||||
mondayService,
|
||||
{ nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId },
|
||||
usuarioMiDia,
|
||||
nombresPorMondayId,
|
||||
capturas,
|
||||
clientes,
|
||||
new Date(),
|
||||
config.automatizaciones.vendedorCompartidoId > 0 ? config.automatizaciones.vendedorCompartidoId : null,
|
||||
capturadosIds,
|
||||
),
|
||||
)
|
||||
)
|
||||
// Unión: capturas locales aún no sincronizadas se agregan con badge ⏳
|
||||
res.json(unirPendientesLocales(resumen, capturas.filter((c) => c.pendienteSync), usuario.nombre))
|
||||
} catch (err) {
|
||||
// Monday se cayó a media lectura: mismo resumen, pero todo local
|
||||
if (!esFalloMonday(err)) throw err
|
||||
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
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) ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
leadCaptura: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
syncPendiente: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
automatizacionState: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
/** Monday mockeado: registra los cambios de columna y los motivos creados */
|
||||
function mondayMock() {
|
||||
@@ -120,3 +146,117 @@ describe('cambiarEtapaOportunidad (POST /api/oportunidades/:id/etapa)', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- 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.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')
|
||||
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)
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'loc_op',
|
||||
usuarioId: 'u-1',
|
||||
nombreLead: 'Luis Demo',
|
||||
telefono: '6641112233',
|
||||
email: null,
|
||||
desarrollo: 'ATELIER',
|
||||
modelo: 'SOHO',
|
||||
datosJson: null,
|
||||
})
|
||||
const { res, json } = resFake()
|
||||
|
||||
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res)
|
||||
|
||||
// Oportunidad en Aparto + apartado local en Pago Apartado + cola
|
||||
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
|
||||
where: { leadId: 'loc_op' },
|
||||
data: { etapa: 'Aparto', pendienteSync: true },
|
||||
})
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado', nombreLead: 'Luis Demo' }),
|
||||
})
|
||||
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA', 'CREAR_APARTADO'])
|
||||
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' }),
|
||||
})
|
||||
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
|
||||
expect(json).toHaveBeenCalledWith({ itemId: 'opp-88', etapa: 'No Asistio' })
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,9 @@ 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'
|
||||
|
||||
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
|
||||
export const MOTIVOS_NO_COMPRA = [
|
||||
@@ -69,7 +72,29 @@ export async function perderOportunidad(
|
||||
export async function perderOportunidadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = perderSchema.parse(req.body)
|
||||
const mondayItemId = String(req.params['mondayItemId'])
|
||||
const motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -123,21 +148,55 @@ export async function cambiarEtapaOportunidad(
|
||||
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.
|
||||
*/
|
||||
async function cambiarEtapaLocal(
|
||||
itemId: string,
|
||||
etapa: (typeof ETAPAS_OPORTUNIDAD_PERMITIDAS)[number],
|
||||
fecha: string | undefined,
|
||||
usuarioId: string | undefined,
|
||||
): Promise<void> {
|
||||
if (etapa === 'Aparto') {
|
||||
await crmLocal.apartar(itemId, usuarioId)
|
||||
return
|
||||
}
|
||||
await crmLocal.cambiarEstado(itemId, etapa, 'OPORTUNIDADES', {
|
||||
fecha,
|
||||
usuarioId,
|
||||
sombra: !esIdLocal(itemId),
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /api/oportunidades/:itemId/etapa { etapa, fecha? } */
|
||||
export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = cambiarEtapaSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
|
||||
// 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') {
|
||||
const usuarioSub = req.usuarioToken?.sub
|
||||
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
|
||||
await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub)
|
||||
} else {
|
||||
try {
|
||||
await automatizacionesService.asegurarApartado(itemId)
|
||||
await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo crear el apartado de la oportunidad ${itemId}:`, err)
|
||||
if (!esFalloMonday(err)) throw err
|
||||
await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
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 {
|
||||
@@ -12,6 +22,11 @@ function mondayMock() {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -30,13 +45,21 @@ describe('notificarEscrituracionSiLiquidado (puente cobranza → postventa)', ()
|
||||
expect(monday.cambios).toEqual([])
|
||||
})
|
||||
|
||||
it('un fallo de Monday no rompe el abono: loguea y devuelve false', async () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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,
|
||||
@@ -231,6 +232,8 @@ export async function notificarEscrituracionSiLiquidado(
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
182
api-gateway/src/controllers/prefill-fallback.test.ts
Normal file
182
api-gateway/src/controllers/prefill-fallback.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
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(() => ({
|
||||
leadCaptura: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
cliente: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
cotizacion: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
const apartadoLocal = {
|
||||
id: 'row-a1',
|
||||
leadId: 'loc_a1',
|
||||
nombreLead: 'ANA DEMO',
|
||||
telefono: '6649998877',
|
||||
email: 'ana@correo.mx',
|
||||
origen: 'WhatsApp',
|
||||
desarrollo: 'SENDA',
|
||||
modelo: '1R',
|
||||
board: 'APARTADO',
|
||||
etapa: 'Pago Apartado',
|
||||
usuarioId: 'u-1',
|
||||
datosJson: null,
|
||||
mondayItemId: null,
|
||||
pendienteSync: true,
|
||||
createdAt: new Date('2026-09-02T10:00:00'),
|
||||
updatedAt: new Date('2026-09-03T10:00:00'),
|
||||
}
|
||||
|
||||
const oportunidadLocal = {
|
||||
...apartadoLocal,
|
||||
id: 'row-o1',
|
||||
leadId: 'loc_o1',
|
||||
board: 'OPORTUNIDADES',
|
||||
etapa: 'Aparto',
|
||||
datosJson: JSON.stringify({ credito: 'Infonavit', edad: 35 }),
|
||||
}
|
||||
|
||||
const clienteLocal = {
|
||||
id: 'c1',
|
||||
nombreCompleto: 'ANA DEMO',
|
||||
curp: 'CURP123',
|
||||
rfc: 'RFC123',
|
||||
email: 'ana@correo.mx',
|
||||
celular: '6649998877',
|
||||
proyecto: 'SENDA',
|
||||
unidadCodigo: 'SENDA-101',
|
||||
mondayApartadoId: 'loc_a1',
|
||||
createdAt: new Date('2026-09-02T11:00:00'),
|
||||
}
|
||||
|
||||
const cotizacionLocal = {
|
||||
id: 'cot1',
|
||||
unidadCodigo: 'SENDA-101',
|
||||
tipoPropuesta: 'INFONAVIT',
|
||||
acabados: true,
|
||||
apartado: 20000,
|
||||
precioLista: 4_415_000,
|
||||
precioFinal: 4_400_000,
|
||||
enganche: 440_000,
|
||||
numMensualidades: 12,
|
||||
mensualidad: 30000,
|
||||
pagoFinal: 100_000,
|
||||
mondayItemId: 'loc_o1',
|
||||
createdAt: new Date('2026-09-01T10:00:00'),
|
||||
}
|
||||
|
||||
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.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)
|
||||
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',
|
||||
nombre: 'ANA DEMO',
|
||||
telefono: '6649998877',
|
||||
email: 'ana@correo.mx',
|
||||
unidadCodigo: 'SENDA-101',
|
||||
estado: 'Pago Apartado',
|
||||
precioFinal: 4_400_000,
|
||||
})
|
||||
expect(prefill['contacto']).toMatchObject({
|
||||
nombre: 'ANA DEMO',
|
||||
Teléfono: '6649998877',
|
||||
CURP: 'CURP123',
|
||||
RFC: 'RFC123',
|
||||
})
|
||||
expect(prefill['oportunidad']).toMatchObject({
|
||||
id: 'loc_o1',
|
||||
desarrollo: 'SENDA',
|
||||
modeloInteres: '1R',
|
||||
credito: 'Infonavit',
|
||||
})
|
||||
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)
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
prefillApartadoEndpoint({ params: { itemId: 'nope' } } as unknown as Request, resFake().res),
|
||||
).rejects.toThrow(NotFoundError)
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
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)
|
||||
const { res, json } = resFake()
|
||||
|
||||
await prefillApartadoEndpoint({ params: { itemId: 'loc_a1' } } as unknown as Request, res)
|
||||
|
||||
const prefill = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(prefill['contacto']).toBeNull()
|
||||
expect(prefill['oportunidad']).toBeNull()
|
||||
expect(prefill['cotizacion']).toBeNull()
|
||||
expect(prefill['apartado']).toMatchObject({ id: 'loc_a1', unidadCodigo: '' })
|
||||
})
|
||||
})
|
||||
15
api-gateway/src/controllers/sistema.controller.ts
Normal file
15
api-gateway/src/controllers/sistema.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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).
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
119
api-gateway/src/controllers/unidades.controller.test.ts
Normal file
119
api-gateway/src/controllers/unidades.controller.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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(() => ({
|
||||
unidad: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
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',
|
||||
desarrollo: 'DIAGONAL',
|
||||
modelo: '2RA',
|
||||
supConstruccion: 70,
|
||||
supExcedente: 0,
|
||||
supEstacionamiento: 12.5,
|
||||
precioLista: 2_000_000,
|
||||
precioAcabados: 2_100_000,
|
||||
status: 'DISPONIBLE',
|
||||
etapa: 'Entrega inmediata',
|
||||
}
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
return { res: { json } as unknown as Response, json }
|
||||
}
|
||||
|
||||
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' }])
|
||||
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({
|
||||
codigo: 'U-101',
|
||||
modelo: '2RA',
|
||||
precioSinAcabados: 2_000_000,
|
||||
precioConAcabados: 2_100_000,
|
||||
status: 'DISPONIBLE',
|
||||
desarrollo: 'DIAGONAL',
|
||||
etapa: 'Entrega inmediata',
|
||||
})
|
||||
})
|
||||
|
||||
it('Monday caído: los filtros ?disponibles y ?desarrollo se aplican igual', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
filaLocal,
|
||||
{ ...filaLocal, codigo: 'U-102', status: 'APARTADO' },
|
||||
{ ...filaLocal, codigo: 'S-1', desarrollo: 'SENDA' },
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarUnidades({ query: { disponibles: 'true', desarrollo: 'senda' } } as unknown as Request, res)
|
||||
|
||||
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
|
||||
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)
|
||||
const { res, json } = resFake()
|
||||
|
||||
await obtenerUnidad({ params: { codigo: 'U-101' } } as unknown as Request, res)
|
||||
expect((json.mock.calls[0]?.[0] as Record<string, unknown>)['codigo']).toBe('U-101')
|
||||
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(null)
|
||||
await expect(
|
||||
obtenerUnidad({ params: { codigo: 'U-X' } } as unknown as Request, resFake().res),
|
||||
).rejects.toThrow(NotFoundError)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { mondayService } from '../services/monday.service.js'
|
||||
import { listarUnidadesConFallback, obtenerUnidadConFallback } from '../services/unidades-local.service.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
|
||||
/** GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE; ?desarrollo= filtra por desarrollo */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function listarUnidades(req: Request, res: Response): Promise<void> {
|
||||
const unidades = await mondayService.getUnidades()
|
||||
const unidades = await listarUnidadesConFallback()
|
||||
const disponibles = req.query['disponibles'] === 'true'
|
||||
const desarrollo = req.query['desarrollo']?.toString().toUpperCase()
|
||||
let lista = disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades
|
||||
@@ -14,9 +17,9 @@ export async function listarUnidades(req: Request, res: Response): Promise<void>
|
||||
res.json(lista)
|
||||
}
|
||||
|
||||
/** GET /api/unidades/:codigo — 404 si no existe */
|
||||
/** GET /api/unidades/:codigo — 404 si no existe ni en Monday ni en el espejo local */
|
||||
export async function obtenerUnidad(req: Request, res: Response): Promise<void> {
|
||||
const unidad = await mondayService.getUnidad(String(req.params['codigo']))
|
||||
const unidad = await obtenerUnidadConFallback(String(req.params['codigo']))
|
||||
if (!unidad) throw new NotFoundError(`Unidad ${String(req.params['codigo'])} no encontrada`)
|
||||
res.json(unidad)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user