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:
2026-09-06 08:08:56 +00:00
parent af2fb10956
commit 60649890eb
70 changed files with 8763 additions and 240 deletions

View File

@@ -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)
})
})

View File

@@ -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)

View File

@@ -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',
}),
})
})
})

View File

@@ -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,
}
}

View 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)
})
})

View File

@@ -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)
}
}

View 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()
})
})

View File

@@ -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 () => {

View File

@@ -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 })

View File

@@ -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' }),
])
})
})

View File

@@ -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))
}

View 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')
})
})

View File

@@ -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))
}
}

View File

@@ -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 })
})
})

View File

@@ -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 } : {}) })

View File

@@ -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()
})
})

View File

@@ -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
}
}

View 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: '' })
})
})

View 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 })
}

View 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)
})
})

View File

@@ -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)
}