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

@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
updateMany: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { MondayService } from './monday.service.js'
import { saludMonday } from './salud-monday.service.js'
/**
* Fallback de actualizarDisponibilidadUnidad (modo local): con Monday caído o
* lanzando 502, el status se actualiza en el espejo local (tabla Unidad) y la
* réplica queda encolada (DISPONIBILIDAD_UNIDAD) para el sync worker.
*/
const crearServicio = (): MondayService =>
new MondayService({ apiKey: 'test', apiUrl: 'https://api.test/v2', baseDelayMs: 1 })
const respuesta500 = (): Response =>
new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' })
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
vi.unstubAllGlobals()
saludMonday.restablecer()
prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 })
prismaMock.syncPendiente.create.mockResolvedValue({})
})
describe('actualizarDisponibilidadUnidad (fallback local)', () => {
it('Monday caído (circuit breaker): actualiza el espejo local y encola DISPONIBILIDAD_UNIDAD sin fetch', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
const fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
await crearServicio().actualizarDisponibilidadUnidad('U-101', 'APARTADA')
expect(fetchSpy).not.toHaveBeenCalled()
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'U-101' },
data: { status: 'APARTADA' },
})
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: {
tipo: 'DISPONIBILIDAD_UNIDAD',
payloadJson: JSON.stringify({ codigo: 'U-101', status: 'APARTADA' }),
referenciaLocal: 'U-101',
},
})
})
it('Monday lanza 502 a media operación: mismo fallback local', async () => {
vi.stubGlobal('fetch', async () => respuesta500())
await crearServicio().actualizarDisponibilidadUnidad('U-102', 'VENDIDA')
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'U-102' },
data: { status: 'VENDIDA' },
})
expect(prismaMock.syncPendiente.create).toHaveBeenCalledOnce()
})
it('unidad inexistente en el espejo local → 404 y no encola', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.unidad.updateMany.mockResolvedValue({ count: 0 })
await expect(crearServicio().actualizarDisponibilidadUnidad('U-X', 'APARTADA')).rejects.toThrow(
'Unidad U-X no encontrada',
)
expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled()
})
it('un fallo al encolar no rompe la escritura local (best-effort)', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.syncPendiente.create.mockRejectedValue(new Error('DB llena'))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await crearServicio().actualizarDisponibilidadUnidad('U-101', 'APARTADA')
expect(prismaMock.unidad.updateMany).toHaveBeenCalledOnce()
consoleSpy.mockRestore()
})
})

View File

@@ -0,0 +1,211 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
leadCaptura: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
cotizacion: {
findMany: vi.fn(),
},
documento: {
findMany: vi.fn(),
},
usuario: {
findMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { detalleContactoLocal, listarContactosLocal } from './contactos-local.service.js'
function clienteFake(parcial: Record<string, unknown> & { id: string }) {
return {
nombreCompleto: `Cliente ${parcial.id}`,
curp: 'CURP123',
rfc: 'RFC123',
email: null,
celular: null,
proyecto: '',
unidadCodigo: null,
payloadJson: '{}',
mondayContactoId: null,
mondayApartadoId: null,
capturadoPorId: null,
createdAt: new Date('2026-09-01T10:00:00'),
...parcial,
}
}
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: 'OPORTUNIDADES',
etapa: 'Afluencia',
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'),
...parcial,
}
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.documento.findMany.mockResolvedValue([])
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }])
})
describe('listarContactosLocal', () => {
it('arma la lista desde Cliente + capturas (OPORTUNIDADES/APARTADO) con la forma de ContactoMonday + fuente local', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567', email: 'juan@correo.mx', proyecto: 'SENDA' }),
])
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877', etapa: 'Pago Apartado', board: 'APARTADO' }),
])
const lista = await listarContactosLocal()
expect(lista).toHaveLength(2)
expect(lista[0]).toMatchObject({
id: 'loc_1', // updatedAt más reciente primero
nombre: 'ANA DEMO',
telefono: '6649998877',
estado: 'Pago Apartado',
fuente: 'local',
})
expect(lista[1]).toMatchObject({
id: 'c1',
nombre: 'JUAN PÉREZ',
telefono: '4771234567',
email: 'juan@correo.mx',
estado: 'Cliente',
fuente: 'local',
})
})
it('deduplica por teléfono (gana el Cliente: tiene la ficha completa)', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({ id: 'c1', celular: '+52 477 123 4567' }),
])
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_1', telefono: '4771234567' }),
])
const lista = await listarContactosLocal()
expect(lista).toHaveLength(1)
expect(lista[0]?.id).toBe('c1')
})
})
describe('detalleContactoLocal', () => {
it('null si el id no existe en ninguna tabla local', async () => {
expect(await detalleContactoLocal('nope')).toBeNull()
})
it('por cliente.id: contacto + propiedad + relaciones + historial derivado de lo local', async () => {
const cliente = clienteFake({
id: 'c1',
nombreCompleto: 'JUAN PÉREZ',
celular: '4771234567',
email: 'juan@correo.mx',
proyecto: 'SENDA',
unidadCodigo: 'SENDA-101',
mondayApartadoId: 'loc_a1',
capturadoPorId: 'u-1',
createdAt: new Date('2026-09-01T10:00:00'),
})
prismaMock.cliente.findFirst.mockResolvedValue(cliente)
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_a1', board: 'APARTADO', etapa: 'Pago Apartado', telefono: '4771234567' }),
capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Aparto', telefono: '4771234567', desarrollo: 'SENDA', modelo: '1R' }),
capturaFake({ leadId: 'loc_x', board: 'LEADS', etapa: 'NUEVO', telefono: '9990001111' }), // otro teléfono: fuera
])
prismaMock.cotizacion.findMany.mockResolvedValue([
{
id: 'cot1',
unidadCodigo: 'SENDA-101',
tipoPropuesta: 'BANCARIO',
precioFinal: 4_400_000,
vendedorId: 'u-1',
createdAt: new Date('2026-08-30T10:00:00'),
},
])
prismaMock.documento.findMany.mockResolvedValue([
{ id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') },
])
const detalle = await detalleContactoLocal('c1')
expect(detalle).not.toBeNull()
if (detalle === null) return
expect(detalle.contacto).toMatchObject({
nombre: 'JUAN PÉREZ',
Teléfono: '4771234567',
Email: 'juan@correo.mx',
CURP: 'CURP123',
RFC: 'RFC123',
})
expect(detalle.propiedad).toMatchObject({
id: 'loc_a1',
unidadCodigo: 'SENDA-101',
desarrollo: 'SENDA',
estado: 'Pago Apartado',
precioFinal: 4_400_000,
atendidoPor: 'Martin Zavala',
})
expect(detalle.relaciones.oportunidades).toHaveLength(1)
expect(detalle.relaciones.oportunidades[0]).toMatchObject({
id: 'loc_o1',
estado: 'Aparto',
atendidoPor: 'Martin Zavala',
detalle: 'SENDA · 1R',
})
expect(detalle.relaciones.apartados[0]).toMatchObject({ id: 'loc_a1', detalle: 'SENDA-101' })
expect(detalle.relaciones.leads).toHaveLength(0) // el de otro teléfono no entra
const detalles = detalle.historial.map((h) => h.detalle)
expect(detalles).toContain('Alta de cliente registrada (SENDA-101)')
expect(detalles).toContain('Documento subido (INE)')
expect(detalles).toContain('Cotización SENDA-101 (BANCARIO)')
expect(detalles.some((d) => d.startsWith('Captura en la app'))).toBe(true)
})
it('por leadId (loc_*): contacto desde la captura, sin propiedad', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(
capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877', etapa: 'Afluencia' }),
)
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877' }),
])
const detalle = await detalleContactoLocal('loc_1')
expect(detalle).not.toBeNull()
if (detalle === null) return
expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877', Estado: 'Afluencia' })
expect(detalle.propiedad).toBeNull()
expect(detalle.relaciones.oportunidades).toHaveLength(1)
expect(detalle.historial.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,211 @@
import type { ContactoMonday } from './monday.service.js'
import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js'
import { prisma } from './prisma.js'
/**
* Fallback de LECTURA del módulo Contactos (modo local): cuando Monday está
* caído, la lista y el detalle se arman desde las tablas locales (Cliente +
* LeadCaptura + Documento + Cotizacion) con la MISMA forma de DTO que el
* camino Monday. Lo que no existe en local (activity log, columnas del board)
* va vacío; el historial se deriva de las capturas/altas/cotizaciones locales.
*/
/** Contacto de la lista con flag aditivo de procedencia (el front lo ignora o lo muestra) */
export type ContactoListaLocal = ContactoMonday & { fuente?: 'local' }
/**
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards
* OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la
* ficha completa), más reciente primero, máx 100 (como getContactos).
*/
export async function listarContactosLocal(): Promise<ContactoListaLocal[]> {
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' }, take: 100 })
const capturas = await prisma.leadCaptura.findMany({
where: { board: { in: ['OPORTUNIDADES', 'APARTADO'] } },
orderBy: { updatedAt: 'desc' },
take: 100,
})
const vistos = new Set<string>()
const lista: ContactoListaLocal[] = []
const agregar = (entrada: ContactoListaLocal): void => {
const clave = claveTelefono(entrada.telefono)
if (clave !== '') {
if (vistos.has(clave)) return
vistos.add(clave)
}
lista.push(entrada)
}
for (const c of clientes) {
agregar({
id: c.id,
nombre: c.nombreCompleto,
telefono: c.celular ?? '',
email: c.email ?? '',
estado: c.proyecto.trim() !== '' ? 'Cliente' : '',
updatedAt: c.createdAt.toISOString(),
fuente: 'local',
})
}
for (const c of capturas) {
agregar({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
estado: c.etapa,
updatedAt: c.updatedAt.toISOString(),
fuente: 'local',
})
}
return lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 100)
}
/**
* Detalle local del contacto. El id puede ser cliente.id, leadId (loc_* o id
* Monday sombra) o mondayContactoId. Las relaciones se arman por teléfono
* (últimos 10 dígitos) o email sobre LeadCaptura, igual que el camino Monday;
* el historial se deriva de capturas + alta + documentos + cotizaciones
* locales (no hay activity log en local). null si no existe en ninguna tabla.
*/
export async function detalleContactoLocal(contactoId: string): Promise<DetalleContacto | null> {
let cliente = await prisma.cliente.findFirst({
where: { OR: [{ id: contactoId }, { mondayContactoId: contactoId }] },
})
const capturaBase = cliente
? null
: await prisma.leadCaptura.findUnique({ where: { leadId: contactoId } })
if (cliente === null && capturaBase === null) return null
const telefono = cliente?.celular ?? capturaBase?.telefono ?? ''
const email = (cliente?.email ?? capturaBase?.email ?? '').trim().toLowerCase()
const clave = claveTelefono(telefono)
// Si el id era de una captura, el Cliente ligado se busca por celular
if (cliente === null && clave !== '') {
cliente = await prisma.cliente.findFirst({
where: { celular: { contains: clave } },
orderBy: { createdAt: 'desc' },
})
}
const capturasTodas = await prisma.leadCaptura.findMany({ orderBy: { createdAt: 'desc' } })
const relacionadas = capturasTodas.filter(
(c) =>
(clave !== '' && c.telefono !== null && claveTelefono(c.telefono) === clave) ||
(email !== '' && (c.email ?? '').trim().toLowerCase() === email),
)
// Cotizaciones y documentos locales del contacto
const cotizaciones =
clave !== ''
? await prisma.cotizacion.findMany({
where: { clienteTelefono: { contains: clave } },
orderBy: { createdAt: 'desc' },
})
: []
const documentos = cliente
? await prisma.documento.findMany({ where: { clienteId: cliente.id }, orderBy: { createdAt: 'desc' } })
: []
// Nombres de capturistas/vendedores (ids locales → nombre para mostrar)
const idsUsuarios = [
...new Set([
...relacionadas.map((c) => c.usuarioId),
...(cliente?.capturadoPorId ? [cliente.capturadoPorId] : []),
...cotizaciones.map((c) => c.vendedorId).filter((id): id is string => id !== null),
]),
]
const usuarios = idsUsuarios.length
? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } })
: []
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
const nombreDe = (id: string | null): string | null => (id !== null ? (nombrePorId.get(id) ?? null) : null)
const unidadCliente = cliente?.unidadCodigo ?? cotizaciones[0]?.unidadCodigo ?? ''
const aRelacion = (c: (typeof relacionadas)[number], detalle: string): RelacionCrm => ({
id: c.leadId,
nombre: c.nombreLead,
estado: c.etapa,
atendidoPor: nombreDe(c.usuarioId),
detalle,
})
const relaciones = {
leads: relacionadas
.filter((c) => c.board === 'LEADS')
.map((c) => aRelacion(c, c.origen ?? '')),
oportunidades: relacionadas
.filter((c) => c.board === 'OPORTUNIDADES')
.map((c) => aRelacion(c, [c.desarrollo, c.modelo].filter((p) => p !== null && p !== '').join(' · '))),
apartados: relacionadas
.filter((c) => c.board === 'APARTADO')
.map((c) => aRelacion(c, c.leadId === cliente?.mondayApartadoId ? unidadCliente : '')),
}
// Propiedad: ficha del Cliente (la comprada o en proceso); sin Cliente no hay
let propiedad: DetalleContacto['propiedad'] = null
if (cliente !== null) {
const apartadoLocal = relacionadas.find((c) => c.leadId === cliente.mondayApartadoId)
propiedad = {
id: cliente.mondayApartadoId ?? cliente.id,
unidadCodigo: unidadCliente,
desarrollo: cliente.proyecto,
estado: apartadoLocal?.etapa ?? '',
precioFinal: cotizaciones[0]?.precioFinal ?? 0,
fechaCompra: cliente.createdAt.toISOString().slice(0, 10),
fechaLimite: '',
atendidoPor: nombreDe(cliente.capturadoPorId),
}
}
const historial: EventoHistorialContacto[] = [
...relacionadas.map((c) => ({
fechaISO: c.createdAt.toISOString(),
usuario: nombreDe(c.usuarioId),
detalle: `Captura en la app (${c.nombreLead})`,
fuente: 'App',
})),
...(cliente !== null
? [
{
fechaISO: cliente.createdAt.toISOString(),
usuario: nombreDe(cliente.capturadoPorId),
detalle: `Alta de cliente registrada${cliente.unidadCodigo !== null ? ` (${cliente.unidadCodigo})` : ''}`,
fuente: 'App',
},
]
: []),
...documentos.map((d) => ({
fechaISO: d.createdAt.toISOString(),
usuario: null,
detalle: `Documento subido (${d.tipo})`,
fuente: 'App',
})),
...cotizaciones.map((c) => ({
fechaISO: c.createdAt.toISOString(),
usuario: nombreDe(c.vendedorId),
detalle: `Cotización ${c.unidadCodigo} (${c.tipoPropuesta})`,
fuente: 'App',
})),
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
const contacto: Record<string, string> =
cliente !== null
? {
nombre: cliente.nombreCompleto,
Teléfono: cliente.celular ?? '',
Email: cliente.email ?? '',
CURP: cliente.curp,
RFC: cliente.rfc,
Estado: 'Cliente',
}
: {
nombre: capturaBase!.nombreLead,
Teléfono: capturaBase!.telefono ?? '',
Email: capturaBase!.email ?? '',
Estado: capturaBase!.etapa,
}
return { contacto, propiedad, relaciones, historial }
}

View File

@@ -0,0 +1,368 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- 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(),
updateMany: vi.fn(),
},
leadOportunidad: {
create: vi.fn(),
findUnique: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
cliente: {
findMany: vi.fn(),
},
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { CrmLocalService, esIdLocal } from './crm-local.service.js'
const svc = new CrmLocalService()
/** Tipos de sync encolados, en orden, a partir de las llamadas al mock */
function tiposEncolados(): string[] {
return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo)
}
function payloadEncolado(tipo: string): Record<string, unknown> {
const llamada = prismaMock.syncPendiente.create.mock.calls.find(
(c) => (c[0] as { data: { tipo: string } }).data.tipo === tipo,
)
if (!llamada) throw new Error(`No se encoló ${tipo}`)
return JSON.parse((llamada[0] as { data: { payloadJson: string } }).data.payloadJson) as Record<string, unknown>
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ id: 'row-nueva', ...data }))
prismaMock.leadCaptura.findFirst.mockResolvedValue(null) // default: sin duplicados
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data)
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.syncPendiente.create.mockResolvedValue({})
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
})
describe('esIdLocal', () => {
it('distingue ids locales de ids de Monday', () => {
expect(esIdLocal('loc_123e4567-e89b-12d3-a456-426614174000')).toBe(true)
expect(esIdLocal('1234567890')).toBe(false)
})
})
describe('CrmLocalService.crearLead', () => {
it('crea la captura board=LEADS etapa NUEVO pendienteSync y encola CREAR_LEAD + ASIGNAR_PERSONA', async () => {
const r = await svc.crearLead(
{ nombre: 'Ana Prueba', telefono: '4771234567', origen: 'WhatsApp', desarrollo: 'SENDA', notas: 'Quiere 2R' },
'u-1',
)
expect('duplicado' in r).toBe(false)
if ('duplicado' in r) return
expect(r.id).toMatch(/^loc_/)
expect(r.oportunidadId).toBeNull()
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({
leadId: r.id,
usuarioId: 'u-1',
nombreLead: 'Ana Prueba',
board: 'LEADS',
etapa: 'NUEVO',
origen: 'WhatsApp',
desarrollo: 'SENDA',
pendienteSync: true,
}),
})
expect(tiposEncolados()).toEqual(['CREAR_LEAD', 'ASIGNAR_PERSONA'])
const payload = payloadEncolado('CREAR_LEAD')
expect(payload['datos']).toMatchObject({ nombre: 'Ana Prueba', telefono: '4771234567', origenLabel: 'WhatsApp' })
expect(payloadEncolado('ASIGNAR_PERSONA')).toEqual({ board: 'leads', usuarioId: 'u-1' })
// Todo quedó en una sola transacción (cambio local + cola atómicos)
expect(prismaMock.$transaction).toHaveBeenCalledOnce()
})
it('sin teléfono repetido no hay duplicado; con teléfono existente devuelve { duplicado }', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_viejo', nombreLead: 'Ana Prueba' })
const r = await svc.crearLead({ nombre: 'Ana Prueba', telefono: '4771234567' })
expect(r).toEqual({ duplicado: { id: 'loc_viejo', nombre: 'Ana Prueba' } })
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled()
})
})
describe('CrmLocalService.crearAfluencia', () => {
it('crea la oportunidad local (board=OPORTUNIDADES, etapa Afluencia) con perfil en datosJson y encola CREAR_AFLUENCIA', async () => {
const r = await svc.crearAfluencia(
{
nombre: 'Luis Demo',
telefono: '6641112233',
desarrollo: 'ATELIER',
modelo: 'SOHO',
edad: 35,
credito: 'Infonavit',
loQuierePara: 'Habitarla',
},
'u-2',
)
expect('duplicado' in r).toBe(false)
if ('duplicado' in r) return
expect(r.id).toMatch(/^loc_/)
expect(r.oportunidadId).toBe(r.id) // la afluencia ES la oportunidad
const creada = prismaMock.leadCaptura.create.mock.calls[0]?.[0] as { data: Record<string, unknown> }
expect(creada.data).toMatchObject({
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
modelo: 'SOHO',
pendienteSync: true,
})
// Perfilamiento completo en datosJson
expect(JSON.parse(creada.data['datosJson'] as string)).toMatchObject({ edad: 35, credito: 'Infonavit' })
// Auto-mapping leadId = oportunidadId (como el flujo Monday)
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ data: { leadId: r.id, oportunidadId: r.id } })
expect(tiposEncolados()).toEqual(['CREAR_AFLUENCIA', 'ASIGNAR_PERSONA'])
expect(payloadEncolado('CREAR_AFLUENCIA')['datos']).toMatchObject({
nombre: 'Luis Demo',
modeloLabel: 'SOHO',
creditoLabel: 'Infonavit',
loQuierePara: 'Habitarla',
})
})
it('con oportunidadId ACTUALIZA la fila local y encola ACTUALIZAR_AFLUENCIA (no crea duplicado)', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'op-55', nombreLead: 'Luis Demo' })
const r = await svc.crearAfluencia({ nombre: 'Luis Demo', oportunidadId: 'op-55', califica: 'Si' }, 'u-2')
expect(r).toEqual({ id: 'op-55', oportunidadId: 'op-55', actualizada: true })
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'op-55' },
data: expect.objectContaining({ etapa: 'Afluencia', pendienteSync: true }),
})
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(tiposEncolados()).toEqual(['ACTUALIZAR_AFLUENCIA'])
expect(payloadEncolado('ACTUALIZAR_AFLUENCIA')['datos']).toMatchObject({ nombre: 'Luis Demo', calificaLabel: 'Si' })
})
it('con teléfono duplicado en OPORTUNIDADES devuelve { duplicado }', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_op', nombreLead: 'Luis Demo' })
const r = await svc.crearAfluencia({ nombre: 'Luis Demo', telefono: '6641112233' })
expect(r).toEqual({ duplicado: { id: 'loc_op', nombre: 'Luis Demo' } })
})
})
describe('CrmLocalService.cambiarEstado', () => {
it('actualiza la etapa local y encola CAMBIO_ETAPA con board y label', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' })
await svc.cambiarEstado('loc_1', 'Agendo Cita', 'OPORTUNIDADES', { fecha: '2026-09-10' })
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_1' },
data: { etapa: 'Agendo Cita', pendienteSync: true },
})
expect(payloadEncolado('CAMBIO_ETAPA')).toEqual({ board: 'oportunidades', label: 'Agendo Cita', fecha: '2026-09-10' })
})
it('sin fila local y sin sombra → 404', async () => {
await expect(svc.cambiarEstado('loc_inexistente', 'Contactado', 'LEADS')).rejects.toMatchObject({ status: 404 })
})
it('con sombra crea la fila mínima (id Monday viejo) y encola igual', async () => {
await svc.cambiarEstado('998877', 'Contactado', 'LEADS', { sombra: true, usuarioId: 'u-1' })
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ leadId: '998877', board: 'LEADS', mondayItemId: '998877', pendienteSync: true }),
})
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA'])
})
})
describe('CrmLocalService.descartar', () => {
it('marca etapa terminal Perdido con motivo/submotivo y encola DESCARTE', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_2', nombreLead: 'Luis Demo' })
await svc.descartar('loc_2', 'Precio', { subcategoria: 'Enganche', comentario: 'Se le hizo caro' })
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_2' },
data: { etapa: 'Perdido', motivo: 'Precio', submotivo: 'Enganche', pendienteSync: true },
})
expect(payloadEncolado('DESCARTE')).toEqual({
fuente: 'oportunidad',
motivo: 'Precio',
nombreItem: 'Luis Demo',
subcategoria: 'Enganche',
comentario: 'Se le hizo caro',
})
})
it('fuente lead: etapa terminal Descartado', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_3', nombreLead: 'Ana' })
await svc.descartar('loc_3', 'Timing', { fuente: 'lead' })
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_3' },
data: expect.objectContaining({ etapa: 'Descartado' }),
})
})
})
describe('CrmLocalService.convertirLead', () => {
const filaLead = {
leadId: 'loc_lead',
oportunidadId: null,
usuarioId: 'u-1',
nombreLead: 'Ana Prueba',
telefono: '4771234567',
email: null,
origen: 'WhatsApp',
desarrollo: 'SENDA',
modelo: null,
datosJson: '{"notas":null}',
}
it('marca el lead Calificado, crea la oportunidad en Agendo Cita, liga y encola CONVERSION', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaLead)
const op = await svc.convertirLead('loc_lead', 'u-1')
expect(op.leadId).toMatch(/^loc_/)
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_lead' },
data: { etapa: 'Calificado', oportunidadId: op.leadId, pendienteSync: true },
})
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({
leadId: op.leadId,
board: 'OPORTUNIDADES',
etapa: 'Agendo Cita',
nombreLead: 'Ana Prueba',
pendienteSync: true,
}),
})
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({
data: { leadId: 'loc_lead', oportunidadId: op.leadId },
})
const payload = payloadEncolado('CONVERSION')
expect(payload['oportunidadIdLocal']).toBe(op.leadId)
expect(payload['datos']).toMatchObject({ nombre: 'Ana Prueba', estadoLabel: 'Agendo Cita', telefono: '4771234567' })
})
it('si el lead ya fue convertido → 409 como el flujo Monday', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ ...filaLead, oportunidadId: 'loc_op_previa' })
await expect(svc.convertirLead('loc_lead')).rejects.toMatchObject({ status: 409 })
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
})
it('lead inexistente → 404', async () => {
await expect(svc.convertirLead('loc_fantasma')).rejects.toMatchObject({ status: 404 })
})
})
describe('CrmLocalService.apartar', () => {
const filaOportunidad = {
leadId: 'loc_op',
usuarioId: 'u-1',
nombreLead: 'Luis Demo',
telefono: '6641112233',
email: 'luis@correo.com',
desarrollo: 'ATELIER',
modelo: 'SOHO',
datosJson: '{"edad":35}',
}
it('pone la oportunidad en Aparto, crea el apartado local (Pago Apartado) y encola CAMBIO_ETAPA + CREAR_APARTADO', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad)
const apartado = await svc.apartar('loc_op', 'u-1')
expect(apartado.leadId).toMatch(/^loc_/)
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',
pendienteSync: true,
}),
})
// Dedup compatible con las automatizaciones
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'apartado-creado:loc_op' } }),
)
expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA', 'CREAR_APARTADO'])
expect(payloadEncolado('CREAR_APARTADO')['datos']).toMatchObject({
clienteNombre: 'Luis Demo',
estadoLabel: 'Pago Apartado',
telefono: '6641112233',
email: 'luis@correo.com',
})
})
it('idempotente: con el dedup escrito devuelve el apartado existente sin encolar de nuevo', async () => {
prismaMock.leadCaptura.findUnique.mockImplementation(async ({ where }: { where: { leadId: string } }) =>
where.leadId === 'loc_op' ? filaOportunidad : { leadId: 'loc_ap_previo', board: 'APARTADO' },
)
prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'apartado-creado:loc_op', valor: 'loc_ap_previo' })
const apartado = await svc.apartar('loc_op', 'u-1')
expect(apartado.leadId).toBe('loc_ap_previo')
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled()
})
})
describe('CrmLocalService.buscarLocales', () => {
it('combina LeadCaptura y Cliente con la forma del picker del alta', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'loc_1',
nombreLead: 'Ana Prueba',
telefono: '4771234567',
email: null,
etapa: 'Afluencia',
origen: 'WhatsApp',
board: 'OPORTUNIDADES',
desarrollo: 'SENDA',
modelo: 'SOHO',
updatedAt: new Date('2026-09-05T10:00:00Z'),
},
])
prismaMock.cliente.findMany.mockResolvedValue([
{
id: 'cli-9',
nombreCompleto: 'Ana Cliente',
celular: '6641112233',
email: 'ana@correo.com',
proyecto: 'ATELIER',
createdAt: new Date('2026-09-04T10:00:00Z'),
},
])
const r = await svc.buscarLocales('ana')
expect(r).toHaveLength(2)
// Más reciente primero: la captura (sep-05) antes que el cliente (sep-04)
expect(r[0]).toEqual({
id: 'loc_1',
nombre: 'Ana Prueba',
telefono: '4771234567',
email: '',
estado: 'Afluencia',
origen: 'WhatsApp',
fuente: 'oportunidad',
desarrollo: 'SENDA',
modeloInteres: 'SOHO',
})
expect(r[1]).toMatchObject({ id: 'cli-9', nombre: 'Ana Cliente', fuente: 'oportunidad', desarrollo: 'ATELIER' })
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] } }),
)
})
})

View File

@@ -0,0 +1,574 @@
import { randomUUID } from 'node:crypto'
import { AppError, NotFoundError } from '../utils/errors.js'
import { claveApartadoCreado } from './automatizaciones.service.js'
import { prisma } from './prisma.js'
import type { TipoSync } from './sync-monday.service.js'
/**
* CRM local (modo degradado): implementa el embudo completo contra SQLite
* mientras Monday está caído y encola cada operación en SyncPendiente para que
* el sync worker la replique cuando Monday vuelva.
*
* - Entidad local del embudo: LeadCaptura (board LEADS | OPORTUNIDADES |
* APARTADO + etapa). Ids locales: `loc_<uuid>` (ver `esIdLocal`).
* - Las operaciones que tocan varias tablas van en $transaction; el encolado
* viaja en la MISMA transacción para que nunca quede un cambio local sin su
* réplica pendiente.
* - SECUENCIAL a propósito (SQLite P1008): nada de Promise.all con escrituras.
*
* Los controllers rutean aquí cuando `saludMonday.estaDisponible()` es false,
* cuando el id es `loc_*` (siempre local hasta que se sincronice) o como
* fallback tras un AppError 502 de Monday.
*/
/** Board local del embudo (columna LeadCaptura.board) */
export type BoardLocal = 'LEADS' | 'OPORTUNIDADES' | 'APARTADO'
/** Board local → valor del payload CAMBIO_ETAPA (boards de config.monday.boards) */
const BOARD_PAYLOAD: Record<BoardLocal, 'leads' | 'oportunidades' | 'apartado'> = {
LEADS: 'leads',
OPORTUNIDADES: 'oportunidades',
APARTADO: 'apartado',
}
/** true si el id es local (aún no existe en Monday) */
export function esIdLocal(id: string): boolean {
return id.startsWith('loc_')
}
function idLocal(): string {
return `loc_${randomUUID()}`
}
const hoyMas = (dias: number): string => {
const d = new Date()
d.setDate(d.getDate() + dias)
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
/** Captura rápida de lead (forma corta de /hoy) */
export interface DatosLeadLocal {
nombre: string
telefono?: string | undefined
email?: string | undefined
origen?: string | undefined
desarrollo?: string | undefined
notas?: string | undefined
}
/** Alta Afluencia: captura ligera + perfilamiento completo */
export interface DatosAfluenciaLocal extends DatosLeadLocal {
/** Con valor: se ACTUALIZA esa oportunidad en vez de crear una nueva */
oportunidadId?: string | undefined
modelo?: string | undefined
medioContacto?: string | undefined
edad?: number | undefined
estadoCivil?: string | undefined
tipoEmpleo?: string | undefined
ocupacion?: string | undefined
rangoSalarial?: string | undefined
credito?: string | undefined
tipoEconomia?: string | undefined
califica?: string | undefined
loQuierePara?: string | undefined
}
/** Misma forma que ResultadoCrearLead del controller (sin importarlo: ciclo) */
export type ResultadoCapturaLocal =
| { id: string; oportunidadId: string | null; actualizada?: boolean }
| { duplicado: { id: string; nombre: string } }
/** Resultado del buscador local (misma forma que ResultadoBusquedaLead) */
export interface ResultadoBusquedaLocal {
id: string
nombre: string
telefono: string
email: string
estado: string
origen: string
fuente: 'lead' | 'oportunidad'
desarrollo: string
modeloInteres: string
}
/**
* Crea un registro en la cola SyncPendiente. Es el ÚNICO punto de encolado del
* modo local: los handlers del worker (sync-handlers.ts) consumen `tipo` +
* `payloadJson` y actualizan los ids Monday usando `referenciaLocal`.
*/
export async function encolarSync(
tipo: TipoSync,
payload: Record<string, unknown>,
referenciaLocal?: string | null,
): Promise<void> {
await prisma.syncPendiente.create({
data: {
tipo,
payloadJson: JSON.stringify(payload),
referenciaLocal: referenciaLocal ?? null,
},
})
}
/**
* Versión best-effort para las réplicas que hoy solo se loguean (alta cliente,
* cotización, checkbox de documento, liquidación): un fallo al encolar NUNCA
* rompe el flujo del caller.
*/
export async function encolarSyncSeguro(
tipo: TipoSync,
payload: Record<string, unknown>,
referenciaLocal?: string | null,
): Promise<void> {
try {
await encolarSync(tipo, payload, referenciaLocal)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[sync] no se pudo encolar ${tipo}:`, err)
}
}
export class CrmLocalService {
/**
* Captura rápida local: LeadCaptura board=LEADS etapa 'NUEVO' + CREAR_LEAD
* encolado. Dedup por teléfono entre los leads locales (mismo contrato 409
* que el flujo Monday). Con usuarioId también se encola la auto-asignación
* del capturista (ASIGNAR_PERSONA) para que quede como su vendedor al
* sincronizarse.
*/
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
if (datos.telefono !== undefined) {
const existente = await prisma.leadCaptura.findFirst({
where: { telefono: datos.telefono, board: 'LEADS' },
})
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } }
}
const id = idLocal()
const datosMonday = {
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
origenLabel: datos.origen,
desarrolloLabel: datos.desarrollo,
notas: datos.notas,
}
const operaciones = [
prisma.leadCaptura.create({
data: {
leadId: id,
oportunidadId: null, // los leads no generan oportunidad
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
board: 'LEADS',
etapa: 'NUEVO',
datosJson: JSON.stringify({ notas: datos.notas ?? null }),
pendienteSync: true,
},
}),
prisma.syncPendiente.create({
data: { tipo: 'CREAR_LEAD', payloadJson: JSON.stringify({ datos: datosMonday }), referenciaLocal: id },
}),
]
if (usuarioId !== undefined) {
operaciones.push(
prisma.syncPendiente.create({
data: {
tipo: 'ASIGNAR_PERSONA',
payloadJson: JSON.stringify({ board: 'leads', usuarioId }),
referenciaLocal: id,
},
}),
)
}
await prisma.$transaction(operaciones)
return { id, oportunidadId: null }
}
/**
* Alta Afluencia local. Sin oportunidadId: LeadCaptura board=OPORTUNIDADES
* etapa 'Afluencia' con el perfilamiento en datosJson (además de los campos
* planos) + auto-mapping LeadOportunidad + CREAR_AFLUENCIA encolado. Con
* oportunidadId (botón "Afluencia" de Mis oportunidades): actualiza la fila
* local si existe y encola ACTUALIZAR_AFLUENCIA (el id puede ser de Monday).
*/
async crearAfluencia(datos: DatosAfluenciaLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
const { oportunidadId, modelo, medioContacto, edad, estadoCivil, tipoEmpleo, ocupacion } = datos
const { rangoSalarial, credito, tipoEconomia, califica, loQuierePara } = datos
const datosMonday = {
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: datos.desarrollo,
origenLabel: datos.origen,
modeloLabel: modelo,
medioContacto,
edad,
estadoCivilLabel: estadoCivil,
tipoEmpleoLabel: tipoEmpleo,
ocupacion,
rangoSalarial,
creditoLabel: credito,
tipoEconomiaLabel: tipoEconomia,
calificaLabel: califica,
loQuierePara,
notas: datos.notas,
}
if (oportunidadId !== undefined) {
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
const operaciones = []
if (existente) {
operaciones.push(
prisma.leadCaptura.update({
where: { leadId: oportunidadId },
data: {
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null,
etapa: 'Afluencia',
datosJson: JSON.stringify(datos),
pendienteSync: true,
},
}),
)
}
operaciones.push(
prisma.syncPendiente.create({
data: {
tipo: 'ACTUALIZAR_AFLUENCIA',
payloadJson: JSON.stringify({ oportunidadId, datos: datosMonday }),
referenciaLocal: oportunidadId,
},
}),
)
await prisma.$transaction(operaciones)
return { id: oportunidadId, oportunidadId, actualizada: true }
}
if (datos.telefono !== undefined) {
const duplicado = await prisma.leadCaptura.findFirst({
where: { telefono: datos.telefono, board: 'OPORTUNIDADES' },
})
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
}
const id = idLocal()
const operaciones = [
prisma.leadCaptura.create({
data: {
leadId: id,
oportunidadId: id, // la afluencia ES la oportunidad
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null,
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
datosJson: JSON.stringify(datos),
pendienteSync: true,
},
}),
// Auto-mapping leadId = oportunidadId (como el flujo Monday): el alta de
// clientes encuentra así la oportunidad a mover a 'Aparto'
prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }),
prisma.syncPendiente.create({
data: { tipo: 'CREAR_AFLUENCIA', payloadJson: JSON.stringify({ datos: datosMonday }), referenciaLocal: id },
}),
]
if (usuarioId !== undefined) {
operaciones.push(
prisma.syncPendiente.create({
data: {
tipo: 'ASIGNAR_PERSONA',
payloadJson: JSON.stringify({ board: 'oportunidades', usuarioId }),
referenciaLocal: id,
},
}),
)
}
await prisma.$transaction(operaciones)
return { id, oportunidadId: id }
}
/**
* Fila local del item, creándola "sombra" si no existe y `sombra` es true.
* Las sombras cubren el fallback 502 sobre ids de Monday antiguos (creados
* antes del modo local): la acción queda reflejada en Mi día y el cambio se
* encola igual. Sin `sombra` → 404.
*/
private async filaLocal(itemId: string, board: BoardLocal, usuarioId: string | undefined, sombra: boolean) {
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
if (row) return row
if (!sombra) throw new NotFoundError(`No existe registro local para el item ${itemId}`)
return prisma.leadCaptura.create({
data: {
leadId: itemId,
usuarioId: usuarioId ?? 'desconocido',
nombreLead: 'Registro previo (Monday)',
board,
etapa: 'NUEVO',
mondayItemId: esIdLocal(itemId) ? null : itemId,
pendienteSync: true,
},
})
}
/**
* Cambio de estado/etapa local (boards LEADS, OPORTUNIDADES y APARTADO):
* actualiza `etapa` (+updatedAt vía Prisma) y encola CAMBIO_ETAPA con el
* label real de la columna 'Estado' (y la fecha de la cita si aplica).
*/
async cambiarEstado(
itemId: string,
label: string,
board: BoardLocal,
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
): Promise<void> {
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
const payload: Record<string, unknown> = { board: BOARD_PAYLOAD[board], label }
if (opciones.fecha !== undefined) payload['fecha'] = opciones.fecha
await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: itemId },
data: { etapa: label, pendienteSync: true },
}),
prisma.syncPendiente.create({
data: { tipo: 'CAMBIO_ETAPA', payloadJson: JSON.stringify(payload), referenciaLocal: itemId },
}),
])
}
/**
* Descarte local: etapa terminal ('Descartado' en Leads, 'Perdido' en
* Oportunidades), guarda motivo/submotivo y encola DESCARTE (el handler
* replica el estado + el item del tablero Motivos No Compra).
*/
async descartar(
itemId: string,
motivo: string,
opciones: {
fuente?: 'lead' | 'oportunidad' | undefined
subcategoria?: string | undefined
comentario?: string | undefined
usuarioId?: string | undefined
sombra?: boolean | undefined
} = {},
): Promise<void> {
const fuente = opciones.fuente ?? 'oportunidad'
const board: BoardLocal = fuente === 'lead' ? 'LEADS' : 'OPORTUNIDADES'
const row = await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
const etapaTerminal = fuente === 'lead' ? 'Descartado' : 'Perdido'
const payload: Record<string, unknown> = { fuente, motivo, nombreItem: row.nombreLead }
if (opciones.subcategoria !== undefined) payload['subcategoria'] = opciones.subcategoria
if (opciones.comentario !== undefined) payload['comentario'] = opciones.comentario
await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: itemId },
data: {
etapa: etapaTerminal,
motivo,
submotivo: opciones.subcategoria ?? null,
pendienteSync: true,
},
}),
prisma.syncPendiente.create({
data: { tipo: 'DESCARTE', payloadJson: JSON.stringify(payload), referenciaLocal: itemId },
}),
])
}
/**
* Conversión local lead → oportunidad (botón "Agendo" de Mis leads): el lead
* queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando
* los datos, se liga LeadOportunidad + `oportunidadId` en la captura y se
* encola CONVERSION. Si el lead ya fue convertido → 409 (como el flujo
* Monday). Devuelve la fila local de la oportunidad.
*/
async convertirLead(leadIdLocal: string, _usuarioId?: string) {
const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } })
if (!lead) throw new NotFoundError('Lead no encontrado')
if (lead.oportunidadId !== null) throw new AppError(409, 'El lead ya fue convertido en oportunidad')
const oportunidadId = idLocal()
const datosOportunidad = {
nombre: lead.nombreLead,
estadoLabel: 'Agendo Cita',
telefono: lead.telefono ?? undefined,
email: lead.email ?? undefined,
origenLabel: lead.origen ?? undefined,
desarrolloLabel: lead.desarrollo ?? undefined,
modeloLabel: lead.modelo ?? undefined,
}
const resultados = await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: leadIdLocal },
data: { etapa: 'Calificado', oportunidadId, pendienteSync: true },
}),
prisma.leadCaptura.create({
data: {
leadId: oportunidadId,
oportunidadId,
usuarioId: lead.usuarioId,
nombreLead: lead.nombreLead,
telefono: lead.telefono,
email: lead.email,
origen: lead.origen,
desarrollo: lead.desarrollo,
modelo: lead.modelo,
board: 'OPORTUNIDADES',
etapa: 'Agendo Cita',
datosJson: lead.datosJson,
pendienteSync: true,
},
}),
prisma.leadOportunidad.create({ data: { leadId: leadIdLocal, oportunidadId } }),
prisma.syncPendiente.create({
data: {
tipo: 'CONVERSION',
payloadJson: JSON.stringify({ oportunidadIdLocal: oportunidadId, datos: datosOportunidad }),
referenciaLocal: leadIdLocal,
},
}),
])
return resultados[1]
}
/**
* "Aparto" local: la oportunidad pasa a etapa 'Aparto' y se crea el apartado
* local (board=APARTADO, etapa 'Pago Apartado', primer paso del pipeline
* Enkontrol) con los datos conocidos. Idempotente con el mismo dedup
* `apartado-creado:<opId>` que las automatizaciones. Encola CAMBIO_ETAPA +
* CREAR_APARTADO (en ese orden: la etapa primero, como en Monday).
*/
async apartar(oportunidadIdLocal: string, usuarioId?: string) {
// Sombra para ids Monday sin fila local (fallback 502 sobre items viejos)
const oportunidad = await this.filaLocal(
oportunidadIdLocal,
'OPORTUNIDADES',
usuarioId,
!esIdLocal(oportunidadIdLocal),
)
const clave = claveApartadoCreado(oportunidadIdLocal)
const previo = await prisma.automatizacionState.findUnique({ where: { clave } })
if (previo !== null) {
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: previo.valor } })
if (existente) return existente
}
const apartadoId = idLocal()
const datosApartado = {
clienteNombre: oportunidad.nombreLead,
unidadCodigo: '', // la unidad se captura en la cotización/alta
fechaApartado: hoyMas(0),
fechaLimite: hoyMas(15),
estadoLabel: 'Pago Apartado',
telefono: oportunidad.telefono ?? undefined,
email: oportunidad.email ?? undefined,
}
const resultados = await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: oportunidadIdLocal },
data: { etapa: 'Aparto', pendienteSync: true },
}),
prisma.leadCaptura.create({
data: {
leadId: apartadoId,
usuarioId: oportunidad.usuarioId,
nombreLead: oportunidad.nombreLead,
telefono: oportunidad.telefono,
email: oportunidad.email,
desarrollo: oportunidad.desarrollo,
modelo: oportunidad.modelo,
board: 'APARTADO',
etapa: 'Pago Apartado',
datosJson: oportunidad.datosJson,
pendienteSync: true,
},
}),
prisma.automatizacionState.upsert({
where: { clave },
update: { valor: apartadoId },
create: { clave, valor: apartadoId },
}),
prisma.syncPendiente.create({
data: {
tipo: 'CAMBIO_ETAPA',
payloadJson: JSON.stringify({ board: 'oportunidades', label: 'Aparto' }),
referenciaLocal: oportunidadIdLocal,
},
}),
prisma.syncPendiente.create({
data: {
tipo: 'CREAR_APARTADO',
payloadJson: JSON.stringify({ datos: datosApartado }),
referenciaLocal: apartadoId,
},
}),
])
return resultados[1]
}
/**
* Búsqueda local para el picker del alta de clientes (y el buscador del
* cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular,
* con la misma forma de respuesta que la búsqueda Monday.
*/
async buscarLocales(termino: string): Promise<ResultadoBusquedaLocal[]> {
const capturas = await prisma.leadCaptura.findMany({
where: { OR: [{ nombreLead: { contains: termino } }, { telefono: { contains: termino } }] },
orderBy: { updatedAt: 'desc' },
take: 15,
})
const clientes = await prisma.cliente.findMany({
where: { OR: [{ nombreCompleto: { contains: termino } }, { celular: { contains: termino } }] },
orderBy: { createdAt: 'desc' },
take: 15,
})
const candidatos = [
...capturas.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
estado: c.etapa,
origen: c.origen ?? '',
fuente: (c.board === 'LEADS' ? 'lead' : 'oportunidad') as 'lead' | 'oportunidad',
desarrollo: c.desarrollo ?? '',
modeloInteres: c.modelo ?? '',
orden: c.updatedAt,
})),
...clientes.map((c) => ({
id: c.id,
nombre: c.nombreCompleto,
telefono: c.celular ?? '',
email: c.email ?? '',
estado: '',
origen: '',
fuente: 'oportunidad' as const,
desarrollo: c.proyecto,
modeloInteres: '',
orden: c.createdAt,
})),
]
return candidatos
.sort((a, b) => b.orden.getTime() - a.orden.getTime())
.slice(0, 15)
.map(({ orden: _orden, ...resto }) => resto)
}
}
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
export const crmLocal = new CrmLocalService()

View File

@@ -0,0 +1,213 @@
import { describe, expect, it } from 'vitest'
import {
resumenMiDiaLocal,
unirPendientesLocales,
type ClienteLocalMiDia,
type FilaLocalMiDia,
type ResumenMiDia,
type UsuarioMiDia,
} from './mi-dia.service.js'
/**
* Mi día en modo local (Monday caído) y la unión durante la transición
* (Monday vivo + capturas locales pendientes de sincronizar).
*/
const AHORA = new Date('2026-09-05T12:00:00')
const vendedor: UsuarioMiDia = { nombre: 'Martin Zavala', rol: 'vendedor', mondayUserId: 123 }
const sinNombre: UsuarioMiDia = { nombre: ' ', rol: 'vendedor', mondayUserId: null }
let consecutivo = 0
function fila(parcial: Partial<FilaLocalMiDia> & { leadId: string }): FilaLocalMiDia {
consecutivo += 1
return {
nombreLead: `Persona ${parcial.leadId}`,
telefono: null,
email: null,
origen: null,
desarrollo: null,
modelo: null,
board: 'LEADS',
etapa: 'NUEVO',
mondayItemId: null,
pendienteSync: true,
createdAt: new Date(AHORA.getTime() - consecutivo * 3_600_000),
updatedAt: new Date(AHORA.getTime() - consecutivo * 1_800_000),
...parcial,
}
}
function cliente(parcial: Partial<ClienteLocalMiDia> & { id: string }): ClienteLocalMiDia {
return {
nombreCompleto: `Cliente ${parcial.id}`,
proyecto: '',
unidadCodigo: null,
mondayApartadoId: null,
createdAt: new Date('2026-09-04T10:00:00'),
...parcial,
}
}
describe('resumenMiDiaLocal (Monday caído)', () => {
it('arma el ResumenMiDia completo desde LeadCaptura por board, sin terminales', () => {
const capturas = [
fila({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO' }),
fila({ leadId: 'loc_l2', board: 'LEADS', etapa: 'Descartado' }), // terminal: fuera
fila({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', desarrollo: 'SENDA', modelo: '1R' }),
fila({ leadId: 'loc_o2', board: 'OPORTUNIDADES', etapa: 'Perdido' }), // terminal: fuera de listas, cuenta en porEtapa
fila({ leadId: 'loc_a1', board: 'APARTADO', etapa: 'Pago Apartado' }),
fila({ leadId: 'loc_a2', board: 'APARTADO', etapa: 'Cancelado' }), // final: fuera
]
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
expect(r.sinMapeoMonday).toBe(false)
expect(r.misLeads.map((l) => l.id).sort()).toEqual(['loc_l1', 'loc_o1'])
expect(r.misLeads.find((l) => l.id === 'loc_l1')?.fuente).toBe('lead')
expect(r.misLeads.find((l) => l.id === 'loc_o1')?.fuente).toBe('oportunidad')
// porEtapa cuenta TODAS las oportunidades (incluye terminales, como Monday)
expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 1, Perdido: 1 })
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_o1'])
expect(r.misOportunidades.items[0]).toMatchObject({ etapa: 'Afluencia', desarrollo: 'SENDA', modeloInteres: '1R' })
expect(r.misApartados.map((a) => a.id)).toEqual(['loc_a1'])
expect(r.misCapturas).toHaveLength(6)
// Lo que no existe en local va vacío/cero sin romper el front
expect(r.misActividadesHoy).toEqual([])
expect(r.apartadosPorVencer).toEqual([])
expect(r.conteos.actividadesAbiertas).toBe(0)
expect(r.equipo).toBeUndefined()
})
it('ultimoCambio viene del updatedAt local y el quién es el capturista ("app" si no hay nombre)', () => {
const capturas = [fila({ leadId: 'loc_l1', board: 'LEADS', updatedAt: new Date('2026-09-05T08:00:00') })]
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
expect(r.misLeads[0]?.ultimoCambio).toEqual({
fechaISO: '2026-09-05T08:00:00.000Z',
usuario: 'Martin Zavala',
detalle: 'Actualización',
})
const rSinNombre = resumenMiDiaLocal(sinNombre, capturas, [], AHORA)
expect(rSinNombre.misLeads[0]?.ultimoCambio?.usuario).toBe('app')
expect(rSinNombre.sinMapeoMonday).toBe(true)
})
it('la unidad del apartado sale del Cliente ligado; estadoApartado de Mis clientes cruza con el apartado local', () => {
const capturas = [fila({ leadId: 'loc_a1', board: 'APARTADO', etapa: 'Pago Apartado' })]
const clientes = [
cliente({ id: 'c1', mondayApartadoId: 'loc_a1', unidadCodigo: 'SENDA-101', proyecto: 'SENDA' }),
cliente({ id: 'c2' }),
]
const r = resumenMiDiaLocal(vendedor, capturas, clientes, AHORA)
expect(r.misApartados[0]?.unidadCodigo).toBe('SENDA-101')
const c1 = r.misClientes.find((c) => c.id === 'c1')
expect(c1).toMatchObject({ completado: true, estadoApartado: 'Pago Apartado' })
expect(r.misClientes.find((c) => c.id === 'c2')?.estadoApartado).toBeNull()
})
it('leadsNuevosSemana cuenta capturas de LEADS + OPORTUNIDADES de los últimos 7 días', () => {
const capturas = [
fila({ leadId: 'loc_l1', board: 'LEADS', createdAt: new Date('2026-09-04T00:00:00') }),
fila({ leadId: 'loc_o1', board: 'OPORTUNIDADES', createdAt: new Date('2026-09-01T00:00:00') }),
fila({ leadId: 'loc_o2', board: 'OPORTUNIDADES', createdAt: new Date('2026-08-20T00:00:00') }), // > 7 días
fila({ leadId: 'loc_a1', board: 'APARTADO', createdAt: new Date('2026-09-04T00:00:00') }), // apartado no cuenta
]
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
expect(r.conteos.leadsNuevosSemana).toBe(2)
})
})
// ---------- Unión (Monday vivo + pendientes locales) ----------
function resumenBase(): ResumenMiDia {
return {
generadoEn: AHORA.toISOString(),
sinMapeoMonday: false,
misLeads: [
{
id: '9001',
nombre: 'Lead Monday',
telefono: '',
email: '',
estado: 'Nuevo',
origen: 'Web',
updatedAt: '2026-09-04T10:00:00.000Z',
fuente: 'lead',
ultimoCambio: null,
},
],
misCapturas: [
{ leadId: 'loc_n1', nombre: 'Cap Local', telefono: '', estado: '', origen: '', capturadoEn: '2026-09-03T10:00:00.000Z' },
{ leadId: '9001', nombre: 'Lead Monday', telefono: '', estado: 'Nuevo', origen: 'Web', capturadoEn: '2026-09-02T10:00:00.000Z' },
],
misClientes: [],
misActividadesHoy: [],
apartadosPorVencer: [],
misOportunidades: {
porEtapa: { Afluencia: 1 },
items: [
{
id: '9002',
nombre: 'Op Monday',
telefono: '',
email: '',
etapa: 'Afluencia',
desarrollo: '',
modeloInteres: '',
unidadCodigo: '',
updatedAt: '2026-09-04T09:00:00.000Z',
ultimoCambio: null,
},
],
},
misApartados: [
{ id: '9003', nombre: 'Apt Monday', unidadCodigo: 'U-1', estado: 'Pago Apartado', fechaLimite: '2026-09-10', ultimoCambio: null },
],
conteos: { leadsNuevosSemana: 1, actividadesAbiertas: 0 },
}
}
describe('unirPendientesLocales (Monday vivo)', () => {
it('agrega los items loc_* a la lista de su board con pendienteSync y re-ordena', () => {
const pendientes = [
fila({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', updatedAt: new Date('2026-09-05T11:00:00') }),
fila({ leadId: 'loc_n2', board: 'OPORTUNIDADES', etapa: 'Afluencia', updatedAt: new Date('2026-09-05T10:00:00') }),
fila({ leadId: 'loc_n3', board: 'APARTADO', etapa: 'Pago Apartado' }),
fila({ leadId: 'loc_n4', board: 'LEADS', etapa: 'Descartado' }), // terminal: no se agrega
]
const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala')
// loc_n1 es más reciente que el de Monday: queda primero
expect(r.misLeads.map((l) => l.id)).toEqual(['loc_n1', 'loc_n2', '9001'])
expect(r.misLeads[0]).toMatchObject({ pendienteSync: true, fuente: 'lead' })
expect(r.misLeads[1]).toMatchObject({ pendienteSync: true, fuente: 'oportunidad' })
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_n2', '9002'])
expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 2 })
expect(r.misApartados.map((a) => a.id)).toEqual(['9003', 'loc_n3']) // sin fecha límite al final
expect(r.misApartados[1]).toMatchObject({ pendienteSync: true })
})
it('fila con mondayItemId (cambio pendiente sobre item Monday): no duplica, solo marca el existente', () => {
const pendientes = [
fila({ leadId: '9001', board: 'LEADS', etapa: 'Contactado', mondayItemId: '9001' }),
fila({ leadId: '9003', board: 'APARTADO', etapa: 'Documentos', mondayItemId: '9003' }),
]
const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala')
expect(r.misLeads).toHaveLength(1)
expect(r.misLeads[0]).toMatchObject({ id: '9001', pendienteSync: true })
expect(r.misApartados).toHaveLength(1)
expect(r.misApartados[0]).toMatchObject({ id: '9003', pendienteSync: true })
})
it('marca Mis capturas y rellena el estado local cuando Monday aún no lo tiene', () => {
const pendientes = [fila({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', origen: 'WhatsApp' })]
const r = unirPendientesLocales(resumenBase(), pendientes, 'Martin Zavala')
const captura = r.misCapturas.find((c) => c.leadId === 'loc_n1')
expect(captura).toMatchObject({ pendienteSync: true, estado: 'NUEVO', origen: 'WhatsApp' })
// La captura ya conocida por Monday conserva su estado
expect(r.misCapturas.find((c) => c.leadId === '9001')?.estado).toBe('Nuevo')
})
})

View File

@@ -52,6 +52,8 @@ export interface LeadMiDia {
fuente: 'lead' | 'oportunidad'
/** Último evento del activity log del board; null si el item no tiene eventos */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
export interface ActividadMiDia {
@@ -78,6 +80,8 @@ export interface CapturaMiDia {
estado: string // estado ACTUAL en Monday; '' si el lead ya no existe en el board
origen: string
capturadoEn: string // ISO
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
export interface ClienteMiDia {
@@ -104,6 +108,8 @@ export interface OportunidadMiDia {
updatedAt: string
/** Último evento del activity log del board; null si el item no tiene eventos */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
@@ -115,6 +121,8 @@ export interface ApartadoMiDia {
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
/** Último evento del activity log del board; null si el item no tiene eventos */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
export interface ResumenMiDia {
@@ -499,3 +507,291 @@ export async function resumenMiDia(
return resumen
}
// ---------- Modo local (Monday caído) y unión durante la transición ----------
/**
* Fila local del embudo (tabla LeadCaptura) tal como la necesita Mi día. El
* controller pasa las filas de Prisma ya filtradas por el usuario; la forma es
* estructural para que los tests usen fixtures llanas.
*/
export interface FilaLocalMiDia {
leadId: string
nombreLead: string
telefono: string | null
email: string | null
origen: string | null
desarrollo: string | null
modelo: string | null
board: string // LEADS | OPORTUNIDADES | APARTADO
etapa: string
/** Item de Monday una vez sincronizado (null en capturas puramente locales) */
mondayItemId: string | null
pendienteSync: boolean
createdAt: Date
updatedAt: Date
}
/** Cliente local tal como lo necesita el modo local (misma forma que en resumenMiDia) */
export interface ClienteLocalMiDia {
id: string
nombreCompleto: string
proyecto: string
unidadCodigo: string | null
mondayApartadoId: string | null
createdAt: Date
}
/** "Último cambio" derivado del updatedAt local; el quién es el capturista ('app' si no hay nombre) */
function ultimoCambioLocal(fila: FilaLocalMiDia, nombreUsuario: string) {
return {
fechaISO: fila.updatedAt.toISOString(),
usuario: nombreUsuario.trim() !== '' ? nombreUsuario : 'app',
detalle: 'Actualización',
}
}
const porUpdatedAtDesc = (a: { updatedAt: string }, b: { updatedAt: string }): number =>
b.updatedAt.localeCompare(a.updatedAt)
/**
* ResumenMiDia COMPLETO construido solo con datos locales (Monday caído).
* Misma forma que resumenMiDia: Mis leads (board LEADS, etapa no terminal),
* Mis oportunidades (board OPORTUNIDADES), Mis apartados (board APARTADO +
* unidad ligada vía Cliente), Mis capturas, Mis clientes. Lo que no existe en
* local va vacío/cero sin romper el front: actividades del día (no hay tabla
* local), apartadosPorVencer (los apartados locales aún no guardan fecha
* límite) y la sección equipo (se omite).
*/
export function resumenMiDiaLocal(
usuario: UsuarioMiDia,
capturas: FilaLocalMiDia[],
clientes: ClienteLocalMiDia[],
ahora: Date = new Date(),
): ResumenMiDia {
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
const leadsLocales = capturas.filter((c) => c.board === 'LEADS')
const oportunidadesLocales = capturas.filter((c) => c.board === 'OPORTUNIDADES')
const apartadosLocales = capturas.filter((c) => c.board === 'APARTADO')
// "Mis leads" mezcla Leads + Oportunidades, igual que el camino Monday
const misLeads: LeadMiDia[] = [
...leadsLocales
.filter((c) => !esEstadoTerminalLead(c.etapa))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
estado: c.etapa,
origen: c.origen ?? '',
updatedAt: c.updatedAt.toISOString(),
fuente: 'lead' as const,
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
pendienteSync: c.pendienteSync,
})),
...oportunidadesLocales
.filter((c) => !esEtapaTerminal(c.etapa))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
estado: c.etapa,
origen: c.origen ?? '',
updatedAt: c.updatedAt.toISOString(),
fuente: 'oportunidad' as const,
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
pendienteSync: c.pendienteSync,
})),
].sort(porUpdatedAtDesc)
// Conteo por etapa de TODAS las oportunidades (incluye terminales, como Monday)
const porEtapa: Record<string, number> = {}
for (const o of oportunidadesLocales) {
const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim()
porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1
}
const itemsOportunidades: OportunidadMiDia[] = oportunidadesLocales
.filter((c) => !esEtapaTerminal(c.etapa))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
etapa: c.etapa,
desarrollo: c.desarrollo ?? '',
modeloInteres: c.modelo ?? '',
unidadCodigo: '',
updatedAt: c.updatedAt.toISOString(),
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
pendienteSync: c.pendienteSync,
}))
.sort(porUpdatedAtDesc)
// La unidad del apartado local se conoce vía el Cliente ligado (alta iniciada)
const unidadPorApartadoId = new Map(
clientes
.filter((c) => c.mondayApartadoId !== null)
.map((c) => [c.mondayApartadoId!, c.unidadCodigo ?? '']),
)
const misApartados: ApartadoMiDia[] = apartadosLocales
.filter((c) => !esEstadoFinalApartado(c.etapa))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa,
fechaLimite: '',
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
pendienteSync: c.pendienteSync,
}))
const misCapturas: CapturaMiDia[] = capturas
.map((c) => ({
leadId: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
estado: c.etapa,
origen: c.origen ?? '',
capturadoEn: c.createdAt.toISOString(),
pendienteSync: c.pendienteSync,
}))
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
const etapaApartadoPorId = new Map(apartadosLocales.map((a) => [a.leadId, a.etapa]))
const misClientes: ClienteMiDia[] = clientes
.map((c) => ({
id: c.id,
nombre: c.nombreCompleto,
proyecto: c.proyecto,
unidadCodigo: c.unidadCodigo ?? '',
completado: c.proyecto.trim() !== '',
capturadoEn: c.createdAt.toISOString(),
estadoApartado:
c.mondayApartadoId != null ? (etapaApartadoPorId.get(c.mondayApartadoId) ?? null) : null,
}))
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
return {
generadoEn: ahora.toISOString(),
sinMapeoMonday: usuario.mondayUserId === null,
misLeads,
misCapturas,
misClientes,
misActividadesHoy: [], // no hay tabla local de actividades
apartadosPorVencer: [], // los apartados locales aún no guardan fecha límite
misOportunidades: { porEtapa, items: itemsOportunidades },
misApartados,
conteos: {
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
(c) => c.createdAt.getTime() >= hace7DiasMs,
).length,
actividadesAbiertas: 0,
},
}
}
/**
* Unión durante la transición (Monday vivo): agrega a las listas del resumen
* los items locales con pendienteSync=true que Monday aún no conoce (ids
* loc_*), con el flag pendienteSync para el badge ⏳ del front. Si la fila
* local ya tiene mondayItemId (cambio encolado sobre un item de Monday), no se
* duplica: solo se marca el item existente. También marca/rellena Mis capturas
* (estado local cuando Monday aún no lo tiene). Muta y devuelve `resumen`.
*/
export function unirPendientesLocales(
resumen: ResumenMiDia,
pendientes: FilaLocalMiDia[],
nombreUsuario: string,
): ResumenMiDia {
const leadsPorId = new Map(resumen.misLeads.map((l) => [l.id, l]))
const oportunidadesPorId = new Map(resumen.misOportunidades.items.map((o) => [o.id, o]))
const apartadosPorId = new Map(resumen.misApartados.map((a) => [a.id, a]))
const capturasPorId = new Map(resumen.misCapturas.map((c) => [c.leadId, c]))
for (const p of pendientes) {
// Mis capturas: flag + estado/origen local cuando Monday aún no lo refleja
const captura = capturasPorId.get(p.leadId)
if (captura) {
captura.pendienteSync = true
if (captura.estado === '') captura.estado = p.etapa
if (captura.origen === '') captura.origen = p.origen ?? ''
}
if (p.mondayItemId !== null) {
// Cambio pendiente sobre un item que Monday ya muestra: solo se marca
const existente =
leadsPorId.get(p.mondayItemId) ??
oportunidadesPorId.get(p.mondayItemId) ??
apartadosPorId.get(p.mondayItemId)
if (existente) existente.pendienteSync = true
continue
}
// Item puramente local (loc_*): se agrega a la lista de su board
if (p.board === 'LEADS' && !esEstadoTerminalLead(p.etapa)) {
resumen.misLeads.push({
id: p.leadId,
nombre: p.nombreLead,
telefono: p.telefono ?? '',
email: p.email ?? '',
estado: p.etapa,
origen: p.origen ?? '',
updatedAt: p.updatedAt.toISOString(),
fuente: 'lead',
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
pendienteSync: true,
})
} else if (p.board === 'OPORTUNIDADES') {
// porEtapa cuenta TODAS las oportunidades (incluye terminales, como Monday)
const etapa = p.etapa.trim() === '' ? '(sin etapa)' : p.etapa.trim()
resumen.misOportunidades.porEtapa[etapa] = (resumen.misOportunidades.porEtapa[etapa] ?? 0) + 1
if (!esEtapaTerminal(p.etapa)) {
resumen.misLeads.push({
id: p.leadId,
nombre: p.nombreLead,
telefono: p.telefono ?? '',
email: p.email ?? '',
estado: p.etapa,
origen: p.origen ?? '',
updatedAt: p.updatedAt.toISOString(),
fuente: 'oportunidad',
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
pendienteSync: true,
})
resumen.misOportunidades.items.push({
id: p.leadId,
nombre: p.nombreLead,
telefono: p.telefono ?? '',
email: p.email ?? '',
etapa: p.etapa,
desarrollo: p.desarrollo ?? '',
modeloInteres: p.modelo ?? '',
unidadCodigo: '',
updatedAt: p.updatedAt.toISOString(),
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
pendienteSync: true,
})
}
} else if (p.board === 'APARTADO' && !esEstadoFinalApartado(p.etapa)) {
resumen.misApartados.push({
id: p.leadId,
nombre: p.nombreLead,
unidadCodigo: '',
estado: p.etapa,
fechaLimite: '',
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
pendienteSync: true,
})
}
}
resumen.misLeads.sort(porUpdatedAtDesc)
resumen.misOportunidades.items.sort(porUpdatedAtDesc)
resumen.misApartados.sort((a, b) => (a.fechaLimite || '9999-12-31').localeCompare(b.fechaLimite || '9999-12-31'))
return resumen
}

View File

@@ -1,6 +1,8 @@
import { config } from '../config/index.js'
import type { UnidadCotizable } from './cotizacion.service.js'
import { AppError } from '../utils/errors.js'
import { prisma } from './prisma.js'
import { saludMonday } from './salud-monday.service.js'
/** Unidad tal como la consume el API (y el frontend del cotizador) */
export type Unidad = UnidadCotizable
@@ -230,12 +232,19 @@ export class MondayService {
for (let intento = 0; intento <= this.maxReintentos; intento++) {
if (intento > 0) await sleep(this.baseDelayMs * 2 ** (intento - 1))
try {
return await this.graphqlOnce<T>(query, variables)
const data = await this.graphqlOnce<T>(query, variables)
saludMonday.registrarExito()
return data
} catch (err) {
if (!(err instanceof MondayRetryableError)) throw err
if (!(err instanceof MondayRetryableError)) {
// Fallo definitivo (AppError 502 de Monday): alimenta el circuit breaker
saludMonday.registrarFallo()
throw err
}
ultimoError = err
}
}
saludMonday.registrarFallo()
throw new AppError(502, `Monday no respondió tras ${this.maxReintentos + 1} intentos: ${ultimoError?.message}`)
}
@@ -363,9 +372,45 @@ export class MondayService {
unidades.push(unidad)
}
this.unidadesCache = { unidades, itemIds, expira: ahora + CACHE_UNIDADES_MS }
// Write-through al espejo local (tabla Unidad): mantiene fresco el
// inventario para el modo local. Best-effort: nunca rompe la lectura.
void this.persistirUnidadesLocal(unidades)
return unidades
}
/**
* Upsert best-effort de las unidades leídas de Monday a la tabla local
* `Unidad` (fallback del cotizador cuando Monday cae). Protegido para poder
* apagarlo en tests; los errores solo se loguean.
*/
protected async persistirUnidadesLocal(unidades: Unidad[]): Promise<void> {
// Los tests mockean fetch y no deben escribir en la base real
if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return
try {
for (const u of unidades) {
const datos = {
desarrollo: u.desarrollo,
modelo: u.modelo,
supConstruccion: u.supConstruccion,
supExcedente: u.supExcedente,
supEstacionamiento: u.supEstacionamiento,
precioLista: u.precioSinAcabados,
precioAcabados: u.precioConAcabados,
status: u.status,
etapa: u.etapa ?? '',
}
await prisma.unidad.upsert({
where: { codigo: u.codigo },
update: datos,
create: { codigo: u.codigo, ...datos },
})
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[unidades] falló el write-through local (se ignora):', err)
}
}
/** Una unidad por código (U-101, U-310...); null si no existe */
async getUnidad(codigo: string): Promise<Unidad | null> {
const unidades = await this.getUnidades()
@@ -724,19 +769,60 @@ export class MondayService {
}
async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise<void> {
const boardId = config.monday.boards.unidades
// Asegura cache fresco para resolver el itemId
await this.getUnidades()
const itemId = this.unidadesCache?.itemIds.get(codigo)
if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`)
const col = await this.columnaId(boardId, 'Disponibilidad')
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
}`,
{ boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) },
)
this.invalidarCacheUnidades()
// Modo local: Monday caído (circuit breaker) → espejo local + réplica encolada
if (!saludMonday.estaDisponible()) {
await this.actualizarDisponibilidadUnidadLocal(codigo, status)
return
}
try {
const boardId = config.monday.boards.unidades
// Asegura cache fresco para resolver el itemId
await this.getUnidades()
const itemId = this.unidadesCache?.itemIds.get(codigo)
if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`)
const col = await this.columnaId(boardId, 'Disponibilidad')
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
}`,
{ boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) },
)
this.invalidarCacheUnidades()
} catch (err) {
// Monday se cayó a media operación: mismo fallback local (el 404 de
// "no existe en Monday" NO es fallo de Monday y se propaga intacto)
if (err instanceof AppError && err.status === 502) {
await this.actualizarDisponibilidadUnidadLocal(codigo, status)
return
}
throw err
}
}
/**
* Fallback local de actualizarDisponibilidadUnidad: actualiza el status en el
* espejo (tabla Unidad) y encola la réplica DISPONIBILIDAD_UNIDAD para que el
* sync worker la aplique en Monday cuando vuelva. 404 si la unidad no está en
* el espejo. El encolado es best-effort (nunca rompe la escritura local).
*/
private async actualizarDisponibilidadUnidadLocal(
codigo: string,
status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA',
): Promise<void> {
const resultado = await prisma.unidad.updateMany({ where: { codigo }, data: { status } })
if (resultado.count === 0) throw new AppError(404, `Unidad ${codigo} no encontrada`)
try {
await prisma.syncPendiente.create({
data: {
tipo: 'DISPONIBILIDAD_UNIDAD',
payloadJson: JSON.stringify({ codigo, status }),
referenciaLocal: codigo,
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[sync] no se pudo encolar DISPONIBILIDAD_UNIDAD de ${codigo}:`, err)
}
}
// ---------- Escrituras genéricas ----------

View File

@@ -0,0 +1,271 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PlanPago } from './cobranza.service.js'
import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js'
import { AppError } from '../utils/errors.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
findMany: vi.fn(),
},
unidad: {
findMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { cargarDatosLocalesPrisma, ReportesService, type DatosLocalesReportes } from './reportes.service.js'
/**
* Fallback de reportes (modo local): Monday caído o 502 → el resumen se
* calcula desde tablas locales (LeadCaptura por board, Unidad por status,
* PlanPago como siempre) con el flag aditivo `parcial: true`.
*/
function pago(id: string, fechaVencimiento: string, monto: number, montoPagado = 0): PlanPago['pagos'][number] {
return { id, numPago: 1, fechaVencimiento, monto, montoPagado, aplicaciones: [] }
}
function plan(id: string, overrides: Partial<PlanPago> = {}): PlanPago {
return {
id,
clienteNombre: `Cliente ${id}`,
unidadCodigo: 'U-101',
proyecto: 'ATELIER',
fechaInicio: '2026-07-01',
engancheTotal: 100000,
numPagos: 2,
mensualidad: 50000,
status: 'ACTIVO',
pagos: [pago(`p-${id}`, '2027-01-01', 50000)], // pendiente y al corriente: ACTIVO
notas: [],
createdAt: '2026-07-01T00:00:00.000Z',
...overrides,
}
}
function leadMonday(id: string, estado = 'Nuevo'): LeadMonday {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-09-04T10:00:00.000Z',
updatedAt: '2026-09-04T10:00:00.000Z',
estado,
origen: 'Web',
telefono: '',
email: '',
vendedorIds: [],
}
}
function unidadMonday(codigo: string, status = 'DISPONIBLE'): Unidad {
return {
codigo,
modelo: '1R',
fechaEntrega: null,
supEstacionamiento: 0,
supConstruccion: 60,
supExcedente: 0,
precioSinAcabados: 1_000_000,
precioConAcabados: 1_100_000,
status,
desarrollo: 'SENDA',
mesesParaFirma: null,
etapa: null,
}
}
function mondayMock(overrides: Record<string, unknown> = {}) {
return {
getLeads: vi.fn(async () => [leadMonday('m1')]),
getOportunidades: vi.fn(async () => [] as OportunidadMonday[]),
getUnidades: vi.fn(async () => [unidadMonday('M-1')]),
getActividadesAbiertas: vi.fn(async () => 3),
...overrides,
}
}
const datosLocales: DatosLocalesReportes = {
leads: [leadMonday('loc_l1'), leadMonday('loc_l2', 'Descartado')],
oportunidades: [
{
id: 'loc_o1',
nombre: 'Op Local',
createdAt: '2026-09-04T10:00:00.000Z',
updatedAt: '2026-09-04T10:00:00.000Z',
etapa: 'Afluencia',
unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: '',
email: '',
origen: 'WhatsApp',
desarrollo: 'SENDA',
modeloInteres: '1R',
credito: 'Infonavit',
},
],
unidades: [unidadMonday('U-1'), unidadMonday('U-2', 'APARTADO')],
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.unidad.findMany.mockResolvedValue([])
})
describe('ReportesService (fallback local)', () => {
it('Monday caído (salud): resumen desde datos locales con parcial: true, sin llamar a Monday', async () => {
const monday = mondayMock()
const svc = new ReportesService(monday, async () => [plan('a')], {
salud: { estaDisponible: () => false },
cargarDatosLocales: async () => datosLocales,
})
const r = await svc.resumen()
expect(r.parcial).toBe(true)
expect(r.leads.total).toBe(2)
expect(r.leads.porEstado).toEqual({ Nuevo: 1, Descartado: 1 })
expect(r.oportunidades.total).toBe(1)
expect(r.oportunidades.porEtapa).toEqual({ Afluencia: 1 })
expect(r.unidades.total).toBe(2)
expect(r.unidades.disponibles).toBe(1)
expect(r.cobranza.planes.activos).toBe(1) // planes locales como siempre
expect(r.actividadesAbiertas).toBe(0) // no hay tabla local de actividades
expect(monday.getLeads).not.toHaveBeenCalled()
})
it('Monday vivo: camino normal intacto (sin parcial) y cache de 60 s', async () => {
const monday = mondayMock()
const cargarPlanes = vi.fn(async () => [plan('a')])
let ahora = 1_000_000
const svc = new ReportesService(monday, cargarPlanes, {
now: () => ahora,
salud: { estaDisponible: () => true },
cargarDatosLocales: async () => datosLocales,
})
const r1 = await svc.resumen()
expect(r1.parcial).toBeUndefined()
expect(r1.leads.total).toBe(1)
expect(r1.actividadesAbiertas).toBe(3)
ahora += 30_000 // dentro del TTL: segunda llamada sale del cache
const r2 = await svc.resumen()
expect(r2).toBe(r1)
expect(monday.getLeads).toHaveBeenCalledTimes(1)
expect(cargarPlanes).toHaveBeenCalledTimes(1)
})
it('Monday lanza AppError 502 a media lectura: cae al resumen local con parcial', async () => {
const monday = mondayMock({ getLeads: vi.fn(async () => { throw new AppError(502, 'Monday no respondió') }) })
const svc = new ReportesService(monday, async () => [], {
salud: { estaDisponible: () => true },
cargarDatosLocales: async () => datosLocales,
})
const r = await svc.resumen()
expect(r.parcial).toBe(true)
expect(r.leads.total).toBe(2)
})
it('otro error de Monday se propaga (no se traga nada)', async () => {
const monday = mondayMock({ getLeads: vi.fn(async () => { throw new AppError(500, 'bug') }) })
const svc = new ReportesService(monday, async () => [], {
salud: { estaDisponible: () => true },
cargarDatosLocales: async () => datosLocales,
})
await expect(svc.resumen()).rejects.toThrow('bug')
})
})
describe('cargarDatosLocalesPrisma (mapeo LeadCaptura/Unidad → DTOs Monday)', () => {
it('mapea boards a LeadMonday/OportunidadMonday con el perfil de datosJson y unidades al DTO', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'loc_l1',
nombreLead: 'Lead Local',
telefono: '4771234567',
email: null,
origen: 'Web',
desarrollo: null,
modelo: null,
board: 'LEADS',
etapa: 'NUEVO',
datosJson: null,
createdAt: new Date('2026-09-04T10:00:00'),
updatedAt: new Date('2026-09-04T11:00:00'),
},
{
leadId: 'loc_o1',
nombreLead: 'Op Local',
telefono: null,
email: 'op@correo.mx',
origen: 'WhatsApp',
desarrollo: 'SENDA',
modelo: '1R',
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
datosJson: JSON.stringify({ estadoCivil: 'Soltero(a)', tipoEmpleo: 'Empleado', credito: 'Infonavit', edad: 35, califica: 'Si' }),
createdAt: new Date('2026-09-03T10:00:00'),
updatedAt: new Date('2026-09-03T11:00:00'),
},
{
leadId: 'loc_a1',
nombreLead: 'Apt Local',
board: 'APARTADO', // el board APARTADO no alimenta leads ni oportunidades
etapa: 'Pago Apartado',
telefono: null,
email: null,
origen: null,
desarrollo: null,
modelo: null,
datosJson: null,
createdAt: new Date('2026-09-02T10:00:00'),
updatedAt: new Date('2026-09-02T11:00:00'),
},
])
prismaMock.unidad.findMany.mockResolvedValue([
{
codigo: 'SENDA-101',
desarrollo: 'SENDA',
modelo: '1R',
supConstruccion: 69.5,
supExcedente: 0,
supEstacionamiento: 12.5,
precioLista: 4_415_000,
precioAcabados: 4_579_000,
status: 'DISPONIBLE',
etapa: '',
},
])
const datos = await cargarDatosLocalesPrisma()
expect(datos.leads).toHaveLength(1)
expect(datos.leads[0]).toMatchObject({ id: 'loc_l1', estado: 'NUEVO', origen: 'Web', telefono: '4771234567' })
expect(datos.oportunidades).toHaveLength(1)
expect(datos.oportunidades[0]).toMatchObject({
id: 'loc_o1',
etapa: 'Afluencia',
desarrollo: 'SENDA',
modeloInteres: '1R',
estadoCivil: 'Soltero(a)',
tipoEmpleo: 'Empleado',
credito: 'Infonavit',
edad: 35,
califica: 'Si',
})
expect(datos.unidades).toHaveLength(1)
expect(datos.unidades[0]).toMatchObject({
codigo: 'SENDA-101',
precioSinAcabados: 4_415_000,
precioConAcabados: 4_579_000,
status: 'DISPONIBLE',
etapa: null,
})
})
})

View File

@@ -1,6 +1,10 @@
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js'
import { ETAPAS_PIPELINE } from './etapas.js'
import { prisma } from './prisma.js'
import { saludMonday, type SaludMondayService } from './salud-monday.service.js'
import { unidadLocalADto } from './unidades-local.service.js'
import { AppError } from '../utils/errors.js'
/**
* Agregador de reportes para la app de tableros (/dash/).
@@ -73,6 +77,8 @@ export interface ResumenReportes {
montoVencido: number
}
actividadesAbiertas: number
/** true cuando el resumen se calculó desde tablas locales (Monday caído): métricas parciales */
parcial?: boolean
}
/** Datos crudos que necesita el agregador (inyectables en tests) */
@@ -285,16 +291,102 @@ type MondayReportes = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'get
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
export type CargadorPlanes = () => Promise<PlanPago[]>
/** Datos del embudo/inventario leídos de tablas locales (modo degradado) */
export interface DatosLocalesReportes {
leads: LeadMonday[]
oportunidades: OportunidadMonday[]
unidades: Unidad[]
}
/** Cargador de los datos locales para el fallback (Prisma en prod, fixture en tests) */
export type CargadorDatosLocales = () => Promise<DatosLocalesReportes>
export interface OpcionesReportes {
/** Reloj inyectable para probar el cache (default Date.now) */
now?: () => number
/** TTL del cache en ms (default 60 s) */
cacheMs?: number
/** Salud de Monday (default: singleton saludMonday) */
salud?: Pick<SaludMondayService, 'estaDisponible'>
/** Datos locales para el fallback (default: tablas LeadCaptura/Unidad vía Prisma) */
cargarDatosLocales?: CargadorDatosLocales
}
/** string de un campo del datosJson de afluencia ('' si no es string) */
function campoTexto(datos: Record<string, unknown>, campo: string): string {
const v = datos[campo]
return typeof v === 'string' ? v : ''
}
/**
* Cargador local por defecto: leads/oportunidades desde LeadCaptura (board +
* etapa; el perfil de la afluencia va en datosJson) e inventario desde la
* tabla Unidad (espejo). Misma forma de DTO que las lecturas Monday.
*/
export async function cargarDatosLocalesPrisma(): Promise<DatosLocalesReportes> {
const capturas = await prisma.leadCaptura.findMany({
where: { board: { in: ['LEADS', 'OPORTUNIDADES'] } },
})
const unidadesRows = await prisma.unidad.findMany()
const leads: LeadMonday[] = capturas
.filter((c) => c.board === 'LEADS')
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
estado: c.etapa,
origen: c.origen ?? '',
telefono: c.telefono ?? '',
email: c.email ?? '',
vendedorIds: [],
}))
const oportunidades: OportunidadMonday[] = capturas
.filter((c) => c.board === 'OPORTUNIDADES')
.map((c) => {
let datos: Record<string, unknown> = {}
if (c.datosJson !== null) {
try {
datos = JSON.parse(c.datosJson) as Record<string, unknown>
} catch {
datos = {}
}
}
const edad = datos['edad']
return {
id: c.leadId,
nombre: c.nombreLead,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
etapa: c.etapa,
unidadCodigo: '',
precioFinal: 0,
asesorIds: [],
telefono: c.telefono ?? '',
email: c.email ?? '',
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
modeloInteres: c.modelo ?? '',
medioContacto: campoTexto(datos, 'medioContacto'),
edad: typeof edad === 'number' && Number.isFinite(edad) ? edad : null,
estadoCivil: campoTexto(datos, 'estadoCivil'),
tipoEmpleo: campoTexto(datos, 'tipoEmpleo'),
credito: campoTexto(datos, 'credito'),
tipoEconomia: campoTexto(datos, 'tipoEconomia'),
califica: campoTexto(datos, 'califica'),
rangoSalarial: campoTexto(datos, 'rangoSalarial'),
loQuierePara: campoTexto(datos, 'loQuierePara'),
}
})
return { leads, oportunidades, unidades: unidadesRows.map(unidadLocalADto) }
}
export class ReportesService {
private readonly now: () => number
private readonly cacheMs: number
private readonly salud: Pick<SaludMondayService, 'estaDisponible'>
private readonly cargarDatosLocales: CargadorDatosLocales
private cache: { resumen: ResumenReportes; expira: number } | null = null
constructor(
@@ -304,6 +396,8 @@ export class ReportesService {
) {
this.now = opciones.now ?? Date.now
this.cacheMs = opciones.cacheMs ?? CACHE_REPORTES_MS
this.salud = opciones.salud ?? saludMonday
this.cargarDatosLocales = opciones.cargarDatosLocales ?? cargarDatosLocalesPrisma
}
/** Invalida el cache (útil tras escrituras que cambian los números) */
@@ -315,16 +409,35 @@ export class ReportesService {
const ahoraMs = this.now()
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getLeads(),
this.monday.getOportunidades(),
this.monday.getUnidades(),
this.monday.getActividadesAbiertas(),
this.cargarPlanes(),
])
const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
const resumen = await this.cargarResumen(ahoraMs)
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
return resumen
}
/**
* Camino normal: Monday + planes locales en paralelo (con el cache de 60 s
* de siempre). Camino degradado: Monday caído (circuit breaker) o AppError
* 502 a media lectura → resumen desde tablas locales con `parcial: true`.
*/
private async cargarResumen(ahoraMs: number): Promise<ResumenReportes> {
if (this.salud.estaDisponible()) {
try {
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
this.monday.getLeads(),
this.monday.getOportunidades(),
this.monday.getUnidades(),
this.monday.getActividadesAbiertas(),
this.cargarPlanes(),
])
return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
} catch (err) {
if (!(err instanceof AppError && err.status === 502)) throw err
// Monday se cayó a media lectura: cae al camino local
}
}
const [locales, planes] = await Promise.all([this.cargarDatosLocales(), this.cargarPlanes()])
const resumen = construirResumen({ ...locales, planes, actividadesAbiertas: 0 }, new Date(ahoraMs))
return { ...resumen, parcial: true }
}
}

View File

@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it } from 'vitest'
import { FALLOS_PARA_CAER, SaludMondayService } from './salud-monday.service.js'
describe('SaludMondayService', () => {
afterEach(() => {
delete process.env.MODO_LOCAL_FORZADO
})
it('arranca disponible (optimista)', () => {
const salud = new SaludMondayService()
expect(salud.ok()).toBe(true)
expect(salud.estaDisponible()).toBe(true)
})
it('cae tras FALLOS_PARA_CAER fallos consecutivos', () => {
const salud = new SaludMondayService()
for (let i = 0; i < FALLOS_PARA_CAER - 1; i++) salud.registrarFallo()
expect(salud.estaDisponible()).toBe(true) // aún aguanta
salud.registrarFallo()
expect(salud.ok()).toBe(false)
expect(salud.estaDisponible()).toBe(false)
})
it('un éxito resetea el contador de fallos', () => {
const salud = new SaludMondayService()
salud.registrarFallo()
salud.registrarFallo()
salud.registrarExito()
salud.registrarFallo()
salud.registrarFallo()
expect(salud.estaDisponible()).toBe(true) // nunca llegó a 3 seguidos
})
it('un éxito recupera el flag una vez caído', () => {
const salud = new SaludMondayService()
for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo()
expect(salud.ok()).toBe(false)
salud.registrarExito()
expect(salud.estaDisponible()).toBe(true)
})
it('MODO_LOCAL_FORZADO=true fuerza modo local aunque Monday esté sano', () => {
const salud = new SaludMondayService()
process.env.MODO_LOCAL_FORZADO = 'true'
expect(salud.ok()).toBe(true) // el flag crudo no cambia
expect(salud.estaDisponible()).toBe(false)
process.env.MODO_LOCAL_FORZADO = 'false'
expect(salud.estaDisponible()).toBe(true)
})
it('sondear() con ping exitoso recupera el flag y devuelve true', async () => {
const salud = new SaludMondayService()
for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo()
salud.registrarPing(async () => true)
await expect(salud.sondear()).resolves.toBe(true)
expect(salud.estaDisponible()).toBe(true)
})
it('sondear() con ping fallido deja el flag caído', async () => {
const salud = new SaludMondayService()
for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo()
salud.registrarPing(async () => false)
await expect(salud.sondear()).resolves.toBe(false)
expect(salud.ok()).toBe(false)
})
it('sondear() sin ping registrado devuelve el flag actual', async () => {
const salud = new SaludMondayService()
await expect(salud.sondear()).resolves.toBe(true)
for (let i = 0; i < FALLOS_PARA_CAER; i++) salud.registrarFallo()
await expect(salud.sondear()).resolves.toBe(false)
})
})

View File

@@ -0,0 +1,73 @@
/**
* Salud de Monday (circuit breaker ligero).
*
* Estado en memoria: `mondayOk` arranca en true (optimista) y pasa a false tras
* FALLOS_PARA_CAER fallos GraphQL consecutivos reportados por monday.service.
* Mientras está caído, los callers deben saltarse Monday de inmediato (modo
* local) en lugar de pagar los reintentos por request; un cron sondea cada 60 s
* y al recuperarse dispara el sync worker.
*
* MODO_LOCAL_FORZADO=true fuerza modo local aunque Monday responda (pruebas).
*
* El ping se inyecta con `registrarPing()` (wiring en index.ts) para no
* importar monday.service aquí y evitar ciclos de imports.
*/
/** Fallos GraphQL consecutivos que tumban el flag */
export const FALLOS_PARA_CAER = 3
export type PingMonday = () => Promise<boolean>
export class SaludMondayService {
private mondayOk = true
private fallosConsecutivos = 0
private pingFn: PingMonday | null = null
/** Registra el callback de ping (en index.ts: () => mondayService.ping()) */
registrarPing(fn: PingMonday): void {
this.pingFn = fn
}
/** Llamar tras cada llamada GraphQL exitosa */
registrarExito(): void {
this.fallosConsecutivos = 0
this.mondayOk = true
}
/** Llamar tras cada fallo definitivo de Monday (AppError 502 de graphql()) */
registrarFallo(): void {
this.fallosConsecutivos += 1
if (this.fallosConsecutivos >= FALLOS_PARA_CAER) this.mondayOk = false
}
/** Flag crudo (sin el override MODO_LOCAL_FORZADO); útil para el cron de sondeo */
ok(): boolean {
return this.mondayOk
}
/** true si los callers pueden hablar con Monday ahora mismo */
estaDisponible(): boolean {
return this.mondayOk && process.env.MODO_LOCAL_FORZADO !== 'true'
}
/**
* Ping a Monday. Si responde, recupera el flag y devuelve true (el caller
* decide si dispara el sync). Sin ping registrado devuelve el flag actual.
*/
async sondear(): Promise<boolean> {
if (this.pingFn === null) return this.mondayOk
const vivo = await this.pingFn()
if (vivo) this.registrarExito()
return vivo
}
/** Reset completo (tests) */
restablecer(): void {
this.mondayOk = true
this.fallosConsecutivos = 0
this.pingFn = null
}
}
/** Singleton compartido por monday.service, controllers y crons */
export const saludMonday = new SaludMondayService()

View File

@@ -0,0 +1,427 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma y Funnel mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
findUnique: vi.fn(),
updateMany: vi.fn(),
},
leadOportunidad: {
create: vi.fn(),
findUnique: vi.fn(),
},
cliente: {
findUnique: vi.fn(),
updateMany: vi.fn(),
},
cotizacion: {
updateMany: vi.fn(),
},
usuario: {
findUnique: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
}))
const funnelMock = vi.hoisted(() => ({
oportunidadPorTelefono: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
import type { StateStore } from './automatizaciones.service.js'
import type { MondayService } from './monday.service.js'
import { registrarHandlersSync } from './sync-handlers.js'
import { SyncMondayService, type SyncRegistro, type SyncStore } from './sync-monday.service.js'
// ---------- Fixtures (mismo patrón que sync-monday.service.test.ts) ----------
let consecutivo = 0
function registro(parcial: Partial<SyncRegistro> = {}): SyncRegistro {
consecutivo += 1
return {
id: parcial.id ?? `reg-${consecutivo}`,
tipo: 'CREAR_LEAD',
payloadJson: '{}',
referenciaLocal: null,
estado: 'PENDIENTE',
intentos: 0,
createdAt: new Date(Date.UTC(2026, 8, 5, 12, 0, consecutivo)),
...parcial,
}
}
function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
const mapa = new Map<string, string>()
return {
mapa,
async get(clave) {
return mapa.get(clave) ?? null
},
async set(clave, valor) {
mapa.set(clave, valor)
},
}
}
function storeEnMemoria(registros: SyncRegistro[]) {
const rows = new Map(registros.map((r) => [r.id, { ...r }]))
const store: SyncStore & { rows: Map<string, SyncRegistro>; ultimo: SyncRegistro } = {
rows,
ultimo: registros[registros.length - 1]!,
async pendientes(limite) {
return [...rows.values()]
.filter((r) => r.estado === 'PENDIENTE')
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.slice(0, limite)
},
async marcar(id, datos) {
const row = rows.get(id)
if (!row) throw new Error(`Registro inexistente: ${id}`)
row.estado = datos.estado
if (datos.intentos !== undefined) row.intentos = datos.intentos
if (datos.ultimoError !== undefined) row.ultimoError = datos.ultimoError
},
}
return store
}
/** Monday mockeado: registra llamadas y devuelve ids controlados */
function mondayMock() {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
leadsCreados: [] as Record<string, unknown>[],
oportunidadesCreadas: [] as Record<string, unknown>[],
contactosCreados: [] as Record<string, unknown>[],
apartadosCreados: [] as Record<string, unknown>[],
motivos: [] as Record<string, unknown>[],
asignaciones: [] as { itemId: string; userId: number }[],
async crearLead(datos: Record<string, unknown>) {
this.leadsCreados.push(datos)
return 'monday-lead-1'
},
async crearOportunidadAfluencia(datos: Record<string, unknown>) {
this.oportunidadesCreadas.push(datos)
return 'monday-op-1'
},
async actualizarOportunidadAfluencia(_id: string, _datos: Record<string, unknown>) {},
async crearContacto(datos: Record<string, unknown>) {
this.contactosCreados.push(datos)
return 'monday-cont-1'
},
async actualizarContacto(_id: string, _datos: Record<string, unknown>) {},
async crearApartado(datos: Record<string, unknown>) {
this.apartadosCreados.push(datos)
return 'monday-apt-1'
},
async crearOportunidad(datos: Record<string, unknown>) {
this.oportunidadesCreadas.push(datos)
return 'monday-op-cot-1'
},
async actualizarOportunidadCotizacion(_id: string, _datos: Record<string, unknown>) {},
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: unknown) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async crearMotivoNoCompra(datos: Record<string, unknown>) {
this.motivos.push(datos)
return 'monday-motivo-1'
},
async asignarVendedorALead(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId })
},
async asignarAsesorAOportunidad(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId })
},
} as unknown as MondayService & {
cambios: { boardId: string; itemId: string; columna: string; valor: unknown }[]
leadsCreados: Record<string, unknown>[]
oportunidadesCreadas: Record<string, unknown>[]
contactosCreados: Record<string, unknown>[]
apartadosCreados: Record<string, unknown>[]
motivos: Record<string, unknown>[]
asignaciones: { itemId: string; userId: number }[]
}
}
const saludSana = { estaDisponible: () => true }
function crearServicio(monday: MondayService, store: SyncStore, state: StateStore) {
const svc = new SyncMondayService(monday, state, store, saludSana)
registrarHandlersSync(svc)
return svc
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.updateMany.mockResolvedValue({})
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
prismaMock.cliente.findUnique.mockResolvedValue(null)
prismaMock.cliente.updateMany.mockResolvedValue({})
prismaMock.cotizacion.updateMany.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
})
describe('handler CREAR_LEAD', () => {
it('crea el lead en Monday y actualiza la fila local (mondayItemId + pendienteSync=false)', async () => {
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'CREAR_LEAD',
payloadJson: JSON.stringify({ datos: { nombre: 'Ana Prueba', telefono: '4771234567' } }),
referenciaLocal: 'loc_ana',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.leadsCreados).toEqual([{ nombre: 'Ana Prueba', telefono: '4771234567' }])
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
where: { leadId: 'loc_ana' },
data: { mondayItemId: 'monday-lead-1', pendienteSync: false },
})
expect(store.rows.get(store.ultimo.id)?.estado).toBe('HECHO')
})
})
describe('handler CONVERSION', () => {
const payloadConversion = {
oportunidadIdLocal: 'loc_op',
datos: { nombre: 'Ana Prueba', estadoLabel: 'Agendo Cita', telefono: '4771234567' },
}
it('sin mondayItemId en el lead (CREATE aún en cola): dependencia pendiente → PENDIENTE sin sumar intento', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_lead',
mondayItemId: null, // su CREAR_LEAD aún no se sincroniza
})
const monday = mondayMock()
const store = storeEnMemoria([
registro({ tipo: 'CONVERSION', payloadJson: JSON.stringify(payloadConversion), referenciaLocal: 'loc_lead' }),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 0 }) // no cuenta como fallo
const row = store.rows.get(store.ultimo.id)
expect(row?.estado).toBe('PENDIENTE')
expect(row?.intentos).toBe(0) // sin sumar intento
expect(row?.ultimoError).toContain('conversión')
expect(monday.oportunidadesCreadas).toEqual([]) // no tocó Monday
})
it('con el lead ya sincronizado: crea la oportunidad, marca el lead Calificado y actualiza ambos ids locales', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_lead', mondayItemId: 'monday-lead-9' })
const monday = mondayMock()
const store = storeEnMemoria([
registro({ tipo: 'CONVERSION', payloadJson: JSON.stringify(payloadConversion), referenciaLocal: 'loc_lead' }),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.oportunidadesCreadas).toEqual([payloadConversion.datos])
// board 444 = MONDAY_BOARD_LEADS de test
expect(monday.cambios).toEqual([
{ boardId: '444', itemId: 'monday-lead-9', columna: 'Estado', valor: 'Calificado' },
])
// Oportunidad local actualizada con su id Monday
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
where: { leadId: 'loc_op' },
data: { mondayItemId: 'monday-op-1', pendienteSync: false },
})
// Mapping con ids Monday para el alta de clientes
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({
data: { leadId: 'monday-lead-9', oportunidadId: 'monday-op-1' },
})
})
})
describe('handler CREAR_CONTACTO', () => {
it('crea el Contacto y liga Cliente.mondayContactoId', async () => {
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'CREAR_CONTACTO',
payloadJson: JSON.stringify({ datos: { nombre: 'Cliente Demo', estadoLabel: 'CLIENTE', curp: 'CURP1' } }),
referenciaLocal: 'cli-1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.contactosCreados).toHaveLength(1)
expect(prismaMock.cliente.updateMany).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayContactoId: 'monday-cont-1' },
})
})
})
describe('handler COTIZACION_OPORTUNIDAD', () => {
const datosCot = {
clienteNombre: 'Cliente Demo',
unidadCodigo: 'U-101',
tipoPropuestaLabel: 'CREDITO BANCARIO',
precioLista: 1_000_000,
descuentoPct: 0,
precioFinal: 1_000_000,
fechaCotizacion: '2026-09-05',
estadoLabel: 'Cotizador',
}
it('con oportunidadId Monday: ACTUALIZA esa oportunidad y liga Cotizacion.mondayItemId', async () => {
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'COTIZACION_OPORTUNIDAD',
payloadJson: JSON.stringify({ datos: datosCot, oportunidadId: 'op-77' }),
referenciaLocal: 'cot-1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.oportunidadesCreadas).toEqual([]) // no duplica
expect(prismaMock.cotizacion.updateMany).toHaveBeenCalledWith({
where: { id: 'cot-1' },
data: { mondayItemId: 'op-77' },
})
})
it('con oportunidadId local (loc_*) sin sincronizar: dependencia pendiente', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op', mondayItemId: null })
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'COTIZACION_OPORTUNIDAD',
payloadJson: JSON.stringify({ datos: datosCot, oportunidadId: 'loc_op' }),
referenciaLocal: 'cot-2',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 0 })
expect(store.rows.get(store.ultimo.id)?.estado).toBe('PENDIENTE')
expect(store.rows.get(store.ultimo.id)?.intentos).toBe(0)
})
it('sin oportunidad: match por teléfono como el controller; sin match crea una nueva', async () => {
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'COTIZACION_OPORTUNIDAD',
payloadJson: JSON.stringify({ datos: datosCot, clienteTelefono: '4771234567' }),
referenciaLocal: 'cot-3',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
await svc.procesarPendientes()
expect(funnelMock.oportunidadPorTelefono).toHaveBeenCalledWith('4771234567')
expect(monday.oportunidadesCreadas).toEqual([datosCot])
expect(prismaMock.cotizacion.updateMany).toHaveBeenCalledWith({
where: { id: 'cot-3' },
data: { mondayItemId: 'monday-op-cot-1' },
})
})
})
describe('handler CAMBIO_ETAPA', () => {
it('aplica el cambio al mondayItemId resuelto desde la referencia local', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'op-55' })
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'CAMBIO_ETAPA',
payloadJson: JSON.stringify({ board: 'oportunidades', label: 'Cotizador' }),
referenciaLocal: 'loc_1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
await svc.procesarPendientes()
// board 555 = MONDAY_BOARD_OPORTUNIDADES de test
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-55', columna: 'Estado', valor: 'Cotizador' }])
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
where: { leadId: 'loc_1' },
data: { pendienteSync: false },
})
})
})
describe('handler APARTO_OPORTUNIDAD', () => {
it('mueve la oportunidad a Aparto con dedup previo (como moverAApartoConDedup)', async () => {
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ leadId: 'lead-9', oportunidadId: 'op-9' })
const monday = mondayMock()
const state = stateEnMemoria()
const store = storeEnMemoria([
registro({ tipo: 'APARTO_OPORTUNIDAD', payloadJson: JSON.stringify({ leadId: 'lead-9' }), referenciaLocal: 'lead-9' }),
])
const svc = crearServicio(monday, store, state)
await svc.procesarPendientes()
// Dedup ANTES del cambio de etapa (prismaStateStore, como moverAApartoConDedup)
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'apartado-creado:op-9' } }),
)
expect(monday.cambios).toEqual([{ boardId: '555', itemId: 'op-9', columna: 'Estado', valor: 'Aparto' }])
})
})
describe('handler LIQUIDADO_ESCRITURAR', () => {
it('mueve el apartado a X Escriturar (board 666 de test)', async () => {
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'LIQUIDADO_ESCRITURAR',
payloadJson: JSON.stringify({ mondayItemId: 'apt-55' }),
referenciaLocal: 'apt-55',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
await svc.procesarPendientes()
expect(monday.cambios).toEqual([{ boardId: '666', itemId: 'apt-55', columna: 'Estado', valor: 'X Escriturar' }])
})
})
describe('handler ASIGNAR_PERSONA', () => {
it('resuelve el mondayUserId del usuario local y asigna en el board correcto', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'lead-55' })
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 777 })
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'ASIGNAR_PERSONA',
payloadJson: JSON.stringify({ board: 'leads', usuarioId: 'u-1' }),
referenciaLocal: 'loc_1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.asignaciones).toEqual([{ itemId: 'lead-55', userId: 777 }])
})
it('sin mapeo Monday del usuario: no-op exitoso (reintentar no crearía el mapeo)', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', mondayItemId: 'lead-55' })
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: null })
const monday = mondayMock()
const store = storeEnMemoria([
registro({
tipo: 'ASIGNAR_PERSONA',
payloadJson: JSON.stringify({ board: 'leads', usuarioId: 'u-1' }),
referenciaLocal: 'loc_1',
}),
])
const svc = crearServicio(monday, store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(monday.asignaciones).toEqual([])
expect(store.rows.get(store.ultimo.id)?.estado).toBe('HECHO')
})
})

View File

@@ -0,0 +1,289 @@
import { config } from '../config/index.js'
import { claveApartadoCreado, prismaStateStore } from './automatizaciones.service.js'
import { esIdLocal } from './crm-local.service.js'
import { funnelService } from './funnel.service.js'
import type {
DatosApartado,
DatosContacto,
DatosLead,
DatosOportunidad,
DatosOportunidadAfluencia,
} from './monday.service.js'
import { prisma } from './prisma.js'
import {
DependenciaPendienteError,
syncMondayService,
type SyncMondayService,
} from './sync-monday.service.js'
/**
* Handlers del sync worker (modo local): una réplica Monday por cada tipo de
* SyncPendiente que encolan crm-local.service y los controllers.
*
* Convenciones:
* - `ctx.referenciaLocal` es el id local de la entidad (leadId loc_* /
* clienteId / cotizacionId); los payloads traen TODO lo necesario para la
* llamada Monday (los mismos datos que mandan hoy los controllers).
* - Cuando la réplica necesita el mondayItemId de algo que aún no se ha creado
* (su CREATE sigue en la cola), se lanza DependenciaPendienteError: el worker
* lo deja PENDIENTE sin sumar intento y lo reintenta en la próxima pasada.
* - Tras el éxito se actualizan los ids locales (LeadCaptura.mondayItemId +
* pendienteSync=false, Cliente.mondayContactoId/mondayApartadoId,
* Cotizacion.mondayItemId).
*
* Wiring: index.ts importa este módulo por su efecto lateral (registro sobre
* el singleton). Los tests llaman `registrarHandlersSync(svcPropio)`.
*/
/**
* Resuelve el id Monday de un item del embudo:
* - id Monday directo (o fila local ya sincronizada): se devuelve tal cual.
* - id local loc_*: el mondayItemId de su fila LeadCaptura; null si su CREATE
* aún no se sincroniza (→ DependenciaPendienteError en el caller).
*/
async function resolverMondayId(id: string | null | undefined): Promise<string | null> {
if (id === null || id === undefined) return null
const row = await prisma.leadCaptura.findUnique({ where: { leadId: id } })
if (row) return row.mondayItemId ?? (esIdLocal(row.leadId) ? null : row.leadId)
// Sin fila local: un id Monday viejo se usa tal cual; un loc_* huérfano no
// tiene destino (null → dependencia pendiente)
return esIdLocal(id) ? null : id
}
/** Lanza DependenciaPendienteError si el id Monday aún no se puede resolver */
function requerirMondayId(id: string | null, que: string): string {
if (id === null) {
throw new DependenciaPendienteError(`${que}: el item aún no existe en Monday (su CREATE sigue en la cola)`)
}
return id
}
/** Marca la fila local del embudo como sincronizada (updateMany: no truena si no existe) */
async function marcarCapturaSincronizada(leadId: string, mondayItemId?: string): Promise<void> {
await prisma.leadCaptura.updateMany({
where: { leadId },
data: { ...(mondayItemId !== undefined ? { mondayItemId } : {}), pendienteSync: false },
})
}
/** Registra todos los handlers sobre el servicio dado (singleton en prod) */
export function registrarHandlersSync(svc: SyncMondayService = syncMondayService): void {
// ---------- Captura (tableros Leads y Oportunidades) ----------
svc.registrarHandler('CREAR_LEAD', async (payload, ctx) => {
const id = await ctx.monday.crearLead(payload['datos'] as DatosLead)
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal, id)
return id
})
svc.registrarHandler('CREAR_AFLUENCIA', async (payload, ctx) => {
const id = await ctx.monday.crearOportunidadAfluencia(payload['datos'] as DatosOportunidadAfluencia)
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal, id)
return id
})
svc.registrarHandler('ACTUALIZAR_AFLUENCIA', async (payload, ctx) => {
const id = requerirMondayId(
await resolverMondayId(ctx.referenciaLocal ?? (payload['oportunidadId'] as string | undefined)),
'actualizar afluencia',
)
await ctx.monday.actualizarOportunidadAfluencia(id, payload['datos'] as DatosOportunidadAfluencia)
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal)
return id
})
svc.registrarHandler('CAMBIO_ETAPA', async (payload, ctx) => {
const board = payload['board'] as 'leads' | 'oportunidades' | 'apartado'
const label = payload['label'] as string
const fecha = payload['fecha'] as string | undefined
const id = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), `cambio de etapa a "${label}"`)
const boardId = config.monday.boards[board]
await ctx.monday.cambiarValorColumna(boardId, id, 'Estado', label)
if (fecha !== undefined) await ctx.monday.cambiarValorColumna(boardId, id, 'Fecha', fecha)
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal)
return id
})
svc.registrarHandler('DESCARTE', async (payload, ctx) => {
const fuente = (payload['fuente'] as 'lead' | 'oportunidad' | undefined) ?? 'oportunidad'
const motivo = payload['motivo'] as string
const subcategoria = payload['subcategoria'] as string | undefined
const comentario = payload['comentario'] as string | undefined
const nombreItem = (payload['nombreItem'] as string | undefined) ?? ''
const id = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'descarte')
if (fuente === 'lead') {
const boardLeads = config.monday.boards.leads
await ctx.monday.cambiarValorColumna(boardLeads, id, 'Estado', 'Descartado')
if (comentario !== undefined) await ctx.monday.cambiarValorColumna(boardLeads, id, 'Notas', comentario)
} else {
const boardOportunidades = config.monday.boards.oportunidades
await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'Estado', 'Perdido')
if (comentario !== undefined) await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'COMENTARIOS', comentario)
await ctx.monday.cambiarValorColumna(boardOportunidades, id, 'Motivo No Compra', motivo)
}
const motivoItemId = await ctx.monday.crearMotivoNoCompra({
nombre: `${motivo}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`,
categoria: motivo,
...(subcategoria !== undefined ? { subcategoria } : {}),
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal)
return motivoItemId
})
svc.registrarHandler('CONVERSION', async (payload, ctx) => {
const oportunidadIdLocal = payload['oportunidadIdLocal'] as string
const datos = payload['datos'] as DatosOportunidadAfluencia
const leadMondayId = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'conversión (lead)')
const oportunidadId = await ctx.monday.crearOportunidadAfluencia(datos)
// El lead queda Calificado (convertido: sale de Mis leads)
await ctx.monday.cambiarValorColumna(config.monday.boards.leads, leadMondayId, 'Estado', 'Calificado')
if (ctx.referenciaLocal !== null) await marcarCapturaSincronizada(ctx.referenciaLocal)
await marcarCapturaSincronizada(oportunidadIdLocal, oportunidadId)
// Mapping con los ids de Monday para el alta de clientes (moverAApartoConDedup)
try {
await prisma.leadOportunidad.create({ data: { leadId: leadMondayId, oportunidadId } })
} catch {
// ya existe (reintento tras pasada a medias): el dedup sync:<id> lo cubre
}
return oportunidadId
})
// ---------- Alta de clientes (tableros Contactos y Apartado) ----------
svc.registrarHandler('CREAR_CONTACTO', async (payload, ctx) => {
const id = await ctx.monday.crearContacto(payload['datos'] as DatosContacto)
if (ctx.referenciaLocal !== null) {
await prisma.cliente.updateMany({ where: { id: ctx.referenciaLocal }, data: { mondayContactoId: id } })
}
return id
})
svc.registrarHandler('ACTUALIZAR_CONTACTO', async (payload, ctx) => {
const cliente = ctx.referenciaLocal !== null
? await prisma.cliente.findUnique({ where: { id: ctx.referenciaLocal } })
: null
const id = cliente?.mondayContactoId ?? null
if (id === null) {
throw new DependenciaPendienteError('actualizar contacto: el Contacto aún no existe en Monday')
}
await ctx.monday.actualizarContacto(id, payload['datos'] as Partial<DatosContacto>)
return id
})
svc.registrarHandler('CREAR_APARTADO', async (payload, ctx) => {
const id = await ctx.monday.crearApartado(payload['datos'] as DatosApartado)
// Alta de clientes: la referencia es el cliente local
if (typeof payload['clienteId'] === 'string') {
await prisma.cliente.updateMany({ where: { id: payload['clienteId'] }, data: { mondayApartadoId: id } })
}
if (ctx.referenciaLocal !== null) {
// "Aparto" local: la referencia es la fila LeadCaptura board=APARTADO
await marcarCapturaSincronizada(ctx.referenciaLocal, id)
// Clientes mínimos creados durante la caída (subida de documentos) ligados
// al id local del apartado: apuntan ahora al item real de Monday
await prisma.cliente.updateMany({
where: { mondayApartadoId: ctx.referenciaLocal },
data: { mondayApartadoId: id },
})
}
return id
})
// ---------- Cotizador ----------
svc.registrarHandler('COTIZACION_OPORTUNIDAD', async (payload, ctx) => {
const datos = payload['datos'] as DatosOportunidad
const oportunidadIdPayload = payload['oportunidadId'] as string | undefined
const clienteTelefono = payload['clienteTelefono'] as string | undefined
let oportunidadId: string | null = null
if (oportunidadIdPayload !== undefined) {
oportunidadId = requerirMondayId(await resolverMondayId(oportunidadIdPayload), 'cotización (oportunidad)')
} else if (clienteTelefono !== undefined) {
// Monday ya está vivo en la pasada: mismo match por teléfono que el controller
oportunidadId = await funnelService.oportunidadPorTelefono(clienteTelefono)
}
if (oportunidadId !== null) {
await ctx.monday.actualizarOportunidadCotizacion(oportunidadId, datos)
} else {
oportunidadId = await ctx.monday.crearOportunidad(datos)
}
if (ctx.referenciaLocal !== null) {
await prisma.cotizacion.updateMany({ where: { id: ctx.referenciaLocal }, data: { mondayItemId: oportunidadId } })
}
return oportunidadId
})
// ---------- Documentos y pipeline de apartados ----------
svc.registrarHandler('CHECKBOX_DOC', async (payload, ctx) => {
const checkbox = payload['checkbox'] as string
// El item Monday del apartado viaja en el payload (puede ser loc_* si el
// apartado nació en modo local: se resuelve por su fila LeadCaptura)
const itemId = (payload['itemId'] as string | undefined) ?? ctx.referenciaLocal
const id = requerirMondayId(await resolverMondayId(itemId), `checkbox "${checkbox}"`)
await ctx.monday.cambiarValorColumna(config.monday.boards.apartado, id, checkbox, true)
return id
})
svc.registrarHandler('APARTO_OPORTUNIDAD', async (payload, ctx) => {
const leadId = payload['leadId'] as string
// Misma resolución que funnel.moverAApartoConDedup: primero como
// oportunidad directa (modelo nuevo), luego como lead con mapping; los ids
// pueden ser locales loc_* (alta hecha en modo local)
let oportunidadId: string | null = null
const porOportunidad = await prisma.leadOportunidad.findUnique({ where: { oportunidadId: leadId } })
if (porOportunidad) {
oportunidadId = porOportunidad.oportunidadId
} else {
const porLead = await prisma.leadOportunidad.findUnique({ where: { leadId } })
oportunidadId = porLead?.oportunidadId ?? null
}
if (oportunidadId === null) {
const captura = await prisma.leadCaptura.findUnique({ where: { leadId } })
if (captura?.board === 'OPORTUNIDADES') oportunidadId = captura.leadId
}
if (oportunidadId === null) throw new Error(`APARTO_OPORTUNIDAD sin mapping para ${leadId}`)
const opMonday = requerirMondayId(await resolverMondayId(oportunidadId), 'mover a Aparto')
// Dedup ANTES del cambio de etapa (el webhook/cron no debe crear el Apartado)
await prismaStateStore.set(claveApartadoCreado(opMonday), new Date().toISOString())
await ctx.monday.cambiarValorColumna(config.monday.boards.oportunidades, opMonday, 'Estado', 'Aparto')
return opMonday
})
svc.registrarHandler('LIQUIDADO_ESCRITURAR', async (payload, ctx) => {
const id = requerirMondayId(await resolverMondayId(payload['mondayItemId'] as string), 'liquidado → X Escriturar')
await ctx.monday.cambiarValorColumna(config.monday.boards.apartado, id, 'Estado', 'X Escriturar')
return id
})
// Réplica del status de una unidad cambiado en modo local (espejo → Monday)
svc.registrarHandler('DISPONIBILIDAD_UNIDAD', async (payload, ctx) => {
const codigo = payload['codigo'] as string
const status = payload['status'] as 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'
await ctx.monday.actualizarDisponibilidadUnidad(codigo, status)
return codigo
})
svc.registrarHandler('ASIGNAR_PERSONA', async (payload, ctx) => {
const board = payload['board'] as 'leads' | 'oportunidades'
const itemId = requerirMondayId(await resolverMondayId(ctx.referenciaLocal), 'asignación de persona')
let userId = (payload['mondayUserId'] as number | undefined) ?? null
if (userId === null && typeof payload['usuarioId'] === 'string') {
const usuario = await prisma.usuario.findUnique({
where: { id: payload['usuarioId'] },
select: { mondayUserId: true },
})
userId = usuario?.mondayUserId ?? null
}
// Sin cuenta Monday vinculada no hay a quién asignar: no es un error
// (reintentar no crearía el mapeo), queda como no-op exitoso
if (userId === null) return 'sin-mapeo'
if (board === 'leads') await ctx.monday.asignarVendedorALead(itemId, userId)
else await ctx.monday.asignarAsesorAOportunidad(itemId, userId)
return itemId
})
}
// Wiring en prod: registrar sobre el singleton al importar el módulo (index.ts)
registrarHandlersSync()

View File

@@ -0,0 +1,225 @@
import { describe, expect, it } from 'vitest'
import { AppError } from '../utils/errors.js'
import type { StateStore } from './automatizaciones.service.js'
import type { MondayService } from './monday.service.js'
import {
claveSync,
esFalloMonday,
MAX_INTENTOS_SYNC,
SyncMondayService,
type SyncRegistro,
type SyncStore,
} from './sync-monday.service.js'
// ---------- Fixtures ----------
let consecutivo = 0
function registro(parcial: Partial<SyncRegistro> = {}): SyncRegistro {
consecutivo += 1
return {
id: parcial.id ?? `reg-${consecutivo}`,
tipo: 'CREAR_LEAD',
payloadJson: JSON.stringify({ nombre: 'Lead de prueba' }),
referenciaLocal: null,
estado: 'PENDIENTE',
intentos: 0,
// createdAt creciente: el orden del arreglo ES el orden FIFO
createdAt: new Date(Date.UTC(2026, 8, 5, 12, 0, consecutivo)),
...parcial,
}
}
/** StateStore en memoria (mismo patrón que automatizaciones.service.test.ts) */
function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
const mapa = new Map<string, string>()
return {
mapa,
async get(clave) {
return mapa.get(clave) ?? null
},
async set(clave, valor) {
mapa.set(clave, valor)
},
}
}
/** SyncStore en memoria: guarda los registros y el historial de marcados */
function storeEnMemoria(registros: SyncRegistro[]) {
const rows = new Map(registros.map((r) => [r.id, { ...r }]))
const marcados: { id: string; datos: { estado: string; intentos?: number; ultimoError?: string | null } }[] = []
const store: SyncStore & { rows: Map<string, SyncRegistro>; marcados: typeof marcados } = {
rows,
marcados,
async pendientes(limite) {
return [...rows.values()]
.filter((r) => r.estado === 'PENDIENTE')
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.slice(0, limite)
},
async marcar(id, datos) {
marcados.push({ id, datos })
const row = rows.get(id)
if (!row) throw new Error(`Registro inexistente: ${id}`)
row.estado = datos.estado
if (datos.intentos !== undefined) row.intentos = datos.intentos
if (datos.ultimoError !== undefined) row.ultimoError = datos.ultimoError
},
}
return store
}
const saludSana = { estaDisponible: () => true }
const saludCaida = { estaDisponible: () => false }
// En fase A no hay handlers por defecto; monday solo se pasa al contexto
const mondayDummy = {} as MondayService
function crearServicio(store: SyncStore, state: StateStore, salud = saludSana) {
return new SyncMondayService(mondayDummy, state, store, salud)
}
// ---------- Tests ----------
describe('esFalloMonday', () => {
it('solo los AppError 502 cuentan como fallo de Monday', () => {
expect(esFalloMonday(new AppError(502, 'Monday no respondió'))).toBe(true)
expect(esFalloMonday(new AppError(500, 'otro'))).toBe(false)
expect(esFalloMonday(new Error('x'))).toBe(false)
})
})
describe('SyncMondayService.procesarPendientes', () => {
it('no-op cuando Monday no está disponible (no toca la cola)', async () => {
const store = storeEnMemoria([registro()])
const state = stateEnMemoria()
const svc = crearServicio(store, state, saludCaida)
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 0 })
expect(store.marcados).toHaveLength(0)
expect(store.rows.get('reg-1')?.estado).toBe('PENDIENTE')
})
it('procesa FIFO y marca HECHO tras ejecutar el handler', async () => {
const store = storeEnMemoria([
registro({ id: 'a', createdAt: new Date('2026-09-05T12:00:02Z') }),
registro({ id: 'b', createdAt: new Date('2026-09-05T12:00:01Z') }),
])
const state = stateEnMemoria()
const svc = crearServicio(store, state)
const orden: string[] = []
svc.registrarHandler('CREAR_LEAD', async (payload) => {
orden.push(String(payload.orden))
return 'item-1'
})
// Reescribo payloads para distinguir orden de ejecución
store.rows.get('a')!.payloadJson = JSON.stringify({ orden: 'segundo-en-fila' })
store.rows.get('b')!.payloadJson = JSON.stringify({ orden: 'primero-en-fila' })
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 2, fallos: 0 })
expect(orden).toEqual(['primero-en-fila', 'segundo-en-fila']) // b (más viejo) primero
expect(store.rows.get('a')?.estado).toBe('HECHO')
expect(store.rows.get('b')?.estado).toBe('HECHO')
// Dedup persistido con el id devuelto por el handler
expect(state.mapa.get(claveSync('a'))).toBe('item-1')
expect(state.mapa.get(claveSync('b'))).toBe('item-1')
})
it('marca ERROR con mensaje claro cuando el tipo no tiene handler', async () => {
const store = storeEnMemoria([registro({ id: 'x', tipo: 'DESCARTE' })])
const svc = crearServicio(store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 1 })
const row = store.rows.get('x')
expect(row?.estado).toBe('ERROR')
expect(row?.ultimoError).toMatch(/sin handler registrado: "DESCARTE"/)
})
it('marca ERROR cuando el payloadJson no es JSON válido', async () => {
const store = storeEnMemoria([registro({ id: 'x', payloadJson: '{roto' })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {})
const r = await svc.procesarPendientes()
expect(r.fallos).toBe(1)
expect(store.rows.get('x')?.estado).toBe('ERROR')
expect(store.rows.get('x')?.ultimoError).toMatch(/payloadJson inválido/)
})
it('fallo Monday (502): vuelve a PENDIENTE con intentos+1 (backoff)', async () => {
const store = storeEnMemoria([registro({ id: 'x', intentos: 2 })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {
throw new AppError(502, 'Monday no respondió tras 4 intentos')
})
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 1 })
const row = store.rows.get('x')
expect(row?.estado).toBe('PENDIENTE')
expect(row?.intentos).toBe(3)
expect(row?.ultimoError).toMatch(/Monday no respondió/)
})
it('al superar MAX_INTENTOS_SYNC el fallo Monday queda ERROR definitivo', async () => {
const store = storeEnMemoria([registro({ id: 'x', intentos: MAX_INTENTOS_SYNC })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {
throw new AppError(502, 'Monday no respondió')
})
await svc.procesarPendientes()
const row = store.rows.get('x')
expect(row?.estado).toBe('ERROR')
expect(row?.intentos).toBe(MAX_INTENTOS_SYNC + 1)
})
it('un error que NO es de Monday marca ERROR sin reintento', async () => {
const store = storeEnMemoria([registro({ id: 'x' })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {
throw new Error('payload incompleto: falta nombre')
})
await svc.procesarPendientes()
expect(store.rows.get('x')?.estado).toBe('ERROR')
expect(store.rows.get('x')?.ultimoError).toMatch(/payload incompleto/)
})
it('idempotencia: con sync:<id> ya en el state no reejecuta el handler', async () => {
const store = storeEnMemoria([registro({ id: 'x' })])
const state = stateEnMemoria()
state.mapa.set(claveSync('x'), 'item-ya-creado')
const svc = crearServicio(store, state)
let llamadas = 0
svc.registrarHandler('CREAR_LEAD', async () => {
llamadas++
})
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(llamadas).toBe(0) // no duplica el item en Monday
expect(store.rows.get('x')?.estado).toBe('HECHO')
})
it('respeta el límite por pasada', async () => {
const store = storeEnMemoria([registro({ id: 'a' }), registro({ id: 'b' }), registro({ id: 'c' })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {})
const r = await svc.procesarPendientes({ limite: 2 })
expect(r.procesados).toBe(2)
expect(store.rows.get('c')?.estado).toBe('PENDIENTE')
})
it('una pasada en curso bloquea la siguiente (mutex)', async () => {
const store = storeEnMemoria([registro({ id: 'x' })])
const svc = crearServicio(store, stateEnMemoria())
let liberar!: () => void
const bloqueo = new Promise<void>((res) => {
liberar = res
})
svc.registrarHandler('CREAR_LEAD', async () => {
await bloqueo
})
const pasada1 = svc.procesarPendientes()
// Segunda pasada mientras la primera sigue: no-op inmediato
await expect(svc.procesarPendientes()).resolves.toEqual({ procesados: 0, fallos: 0 })
liberar()
await expect(pasada1).resolves.toEqual({ procesados: 1, fallos: 0 })
})
})

View File

@@ -0,0 +1,216 @@
import { AppError } from '../utils/errors.js'
import { prismaStateStore, type StateStore } from './automatizaciones.service.js'
import { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js'
import { saludMonday, type SaludMondayService } from './salud-monday.service.js'
/**
* Sync worker del modo local: drena la cola SyncPendiente hacia Monday.
*
* - Solo corre cuando Monday está disponible (saludMonday); si está caído es no-op.
* - FIFO por createdAt (la captura encoló en orden: lead → conversión →
* afluencia → apartado → contacto) y SECUENCIAL: SQLite serializa las
* escrituras (P1008) si se procesa en paralelo.
* - Idempotencia con AutomatizacionState, clave `sync:<id>` (mismo patrón que
* `apartado-creado:<id>` de automatizaciones): si el proceso muere a media
* pasada, al reintentar no duplica el item en Monday.
* - Fallo Monday (AppError 502): vuelve a PENDIENTE con intentos+1; al superar
* MAX_INTENTOS queda ERROR (visible en /api/sistema/estado). Cualquier otro
* error (tipo sin handler, payload inválido) marca ERROR de inmediato.
*
* Los handlers por tipo los registra la fase B (con los payloads reales de
* cada flujo) vía `registrarHandler`; el mapa queda extensible y defensivo.
*/
export const TIPOS_SYNC = [
'CREAR_LEAD',
'CREAR_AFLUENCIA',
'ACTUALIZAR_AFLUENCIA',
'CAMBIO_ETAPA',
'DESCARTE',
'CONVERSION',
'CREAR_CONTACTO',
'ACTUALIZAR_CONTACTO',
'CREAR_APARTADO',
'COTIZACION_OPORTUNIDAD',
'CHECKBOX_DOC',
'APARTO_OPORTUNIDAD',
'LIQUIDADO_ESCRITURAR',
'ASIGNAR_PERSONA',
'DISPONIBILIDAD_UNIDAD',
] as const
export type TipoSync = (typeof TIPOS_SYNC)[number]
/** Intentos máximos antes de declarar ERROR definitivo */
export const MAX_INTENTOS_SYNC = 10
/** Registro de la cola tal como lo consume el worker */
export interface SyncRegistro {
id: string
tipo: string
payloadJson: string
referenciaLocal: string | null
estado: string
intentos: number
createdAt: Date
}
/** Acceso mínimo a la tabla SyncPendiente (Prisma en prod, memoria en tests) */
export interface SyncStore {
/** Hasta `limite` registros PENDIENTE en orden FIFO (createdAt asc) */
pendientes(limite: number): Promise<SyncRegistro[]>
marcar(
id: string,
datos: { estado: string; intentos?: number; ultimoError?: string | null },
): Promise<void>
}
export interface ContextoSync {
monday: MondayService
referenciaLocal: string | null
}
/**
* Ejecuta la réplica en Monday. Devuelve el id creado (o cualquier cadena) para
* guardarlo en el dedup `sync:<id>`; void se persiste como 'ok'.
*/
export type HandlerSync = (
payload: Record<string, unknown>,
ctx: ContextoSync,
) => Promise<string | void>
export interface ResultadoSync {
procesados: number
fallos: number
}
/** true si el error es un fallo de Monday retryable (AppError 502 de graphql) */
export function esFalloMonday(err: unknown): boolean {
return err instanceof AppError && err.status === 502
}
/**
* El handler no puede ejecutarse TODAVÍA porque una operación previa de la
* cola aún no se sincroniza (p.ej. CAMBIO_ETAPA sobre un item cuyo CREATE no
* ha corrido: no hay mondayItemId). El worker lo deja PENDIENTE SIN sumar
* intento: se reintenta en la siguiente pasada, cuando su dependencia ya se
* haya aplicado (la cola es FIFO).
*/
export class DependenciaPendienteError extends Error {
constructor(message: string) {
super(message)
this.name = 'DependenciaPendienteError'
}
}
/** Clave de dedup/idempotencia del registro en AutomatizacionState */
export function claveSync(id: string): string {
return `sync:${id}`
}
export class SyncMondayService {
private readonly handlers = new Map<string, HandlerSync>()
/** Mutex en memoria: evita solapar el cron de 2 min con el disparo al recuperar salud */
private enEjecucion = false
constructor(
private readonly monday: MondayService,
private readonly state: StateStore,
private readonly db: SyncStore,
private readonly salud: Pick<SaludMondayService, 'estaDisponible'> = saludMonday,
) {}
/** Registra el handler de un tipo de réplica (lo usa la fase B por flujo) */
registrarHandler(tipo: TipoSync, handler: HandlerSync): void {
this.handlers.set(tipo, handler)
}
/**
* Una pasada por la cola (máx `limite` registros). No-op si Monday está
* caído / MODO_LOCAL_FORZADO, o si ya hay una pasada en curso.
*/
async procesarPendientes({ limite = 25 }: { limite?: number } = {}): Promise<ResultadoSync> {
if (!this.salud.estaDisponible()) return { procesados: 0, fallos: 0 }
if (this.enEjecucion) return { procesados: 0, fallos: 0 }
this.enEjecucion = true
try {
const pendientes = await this.db.pendientes(limite)
let procesados = 0
let fallos = 0
for (const registro of pendientes) {
await this.db.marcar(registro.id, { estado: 'PROCESANDO' })
try {
await this.ejecutar(registro)
await this.db.marcar(registro.id, { estado: 'HECHO', ultimoError: null })
procesados++
} catch (err) {
const mensaje = err instanceof Error ? err.message : String(err)
if (err instanceof DependenciaPendienteError) {
// Falta una operación previa de la cola: queda PENDIENTE sin sumar
// intento (no es un fallo; se reintenta en la próxima pasada)
await this.db.marcar(registro.id, { estado: 'PENDIENTE', ultimoError: mensaje })
} else {
fallos++
const intentos = registro.intentos + 1
if (esFalloMonday(err)) {
// Monday volvió a caer a media pasada: reintenta en la siguiente
await this.db.marcar(registro.id, {
estado: intentos > MAX_INTENTOS_SYNC ? 'ERROR' : 'PENDIENTE',
intentos,
ultimoError: mensaje,
})
} else {
// Error de datos/código: reintentar no lo arregla
await this.db.marcar(registro.id, { estado: 'ERROR', intentos, ultimoError: mensaje })
}
}
}
}
if (procesados + fallos > 0) {
// eslint-disable-next-line no-console
console.log(`[sync] procesados ${procesados}, fallos ${fallos}`)
}
return { procesados, fallos }
} finally {
this.enEjecucion = false
}
}
/** Ejecuta un registro con dedup `sync:<id>`; lanza para que el caller clasifique */
private async ejecutar(registro: SyncRegistro): Promise<void> {
const handler = this.handlers.get(registro.tipo)
if (!handler) throw new Error(`Tipo de sync sin handler registrado: "${registro.tipo}"`)
let payload: Record<string, unknown>
try {
payload = JSON.parse(registro.payloadJson) as Record<string, unknown>
} catch {
throw new Error(`payloadJson inválido (no es JSON): registro ${registro.id}`)
}
const clave = claveSync(registro.id)
const previo = await this.state.get(clave)
if (previo !== null) return // ya se aplicó en una pasada previa (dedup)
const resultado = await handler(payload, {
monday: this.monday,
referenciaLocal: registro.referenciaLocal,
})
await this.state.set(clave, typeof resultado === 'string' && resultado !== '' ? resultado : 'ok')
}
}
/** Store respaldado por la tabla SyncPendiente de Prisma */
export const prismaSyncStore: SyncStore = {
pendientes(limite) {
return prisma.syncPendiente.findMany({
where: { estado: 'PENDIENTE' },
orderBy: { createdAt: 'asc' },
take: limite,
})
},
async marcar(id, datos) {
await prisma.syncPendiente.update({ where: { id }, data: datos })
},
}
/** Singleton usado por los crons de index.ts (los tests instancian el suyo) */
export const syncMondayService = new SyncMondayService(mondayService, prismaStateStore, prismaSyncStore)

View File

@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppError } 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 {
getUnidadesLocal,
getUnidadLocal,
listarUnidadesConFallback,
obtenerUnidadConFallback,
unidadLocalADto,
type FilaUnidad,
} from './unidades-local.service.js'
import { saludMonday } from './salud-monday.service.js'
function fila(parcial: Partial<FilaUnidad> & { codigo: string }): FilaUnidad {
return {
desarrollo: 'SENDA',
modelo: '1R',
supConstruccion: 69.5,
supExcedente: 0,
supEstacionamiento: 12.5,
precioLista: 4_415_000,
precioAcabados: 4_579_000,
status: 'DISPONIBLE',
etapa: 'Entrega inmediata',
...parcial,
}
}
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.unidad.findMany.mockResolvedValue([])
prismaMock.unidad.findUnique.mockResolvedValue(null)
})
describe('unidadLocalADto', () => {
it('mapea la fila local al DTO Unidad (misma forma que Monday)', () => {
const dto = unidadLocalADto(fila({ codigo: 'SENDA-101' }))
expect(dto).toEqual({
codigo: 'SENDA-101',
modelo: '1R',
fechaEntrega: null,
supEstacionamiento: 12.5,
supConstruccion: 69.5,
supExcedente: 0,
precioSinAcabados: 4_415_000,
precioConAcabados: 4_579_000,
status: 'DISPONIBLE',
desarrollo: 'SENDA',
mesesParaFirma: null,
etapa: 'Entrega inmediata',
})
})
it('etapa vacía → null (como el mapeo Monday)', () => {
expect(unidadLocalADto(fila({ codigo: 'U-1', etapa: '' })).etapa).toBeNull()
})
})
describe('getUnidadesLocal / getUnidadLocal', () => {
it('devuelve las filas mapeadas; soloDisponibles y desarrollo filtran', async () => {
prismaMock.unidad.findMany.mockResolvedValue([
fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }),
fila({ codigo: 'U-2', desarrollo: 'SENDA', status: 'APARTADO' }),
fila({ codigo: 'U-3', desarrollo: 'senda' }),
])
const todas = await getUnidadesLocal()
expect(todas.map((u) => u.codigo)).toEqual(['U-1', 'U-2', 'U-3'])
const disponibles = await getUnidadesLocal({ soloDisponibles: true })
expect(disponibles.map((u) => u.codigo)).toEqual(['U-1', 'U-3'])
const senda = await getUnidadesLocal({ desarrollo: 'SENDA' })
expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3'])
})
it('getUnidadLocal: null si no existe', async () => {
prismaMock.unidad.findUnique.mockImplementation(async ({ where }) =>
where.codigo === 'U-9' ? fila({ codigo: 'U-9' }) : null,
)
expect((await getUnidadLocal('U-9'))?.codigo).toBe('U-9')
expect(await getUnidadLocal('U-X')).toBeNull()
})
})
describe('fallbacks con circuit breaker', () => {
it('Monday caído (salud) → lee local sin llamar a Monday', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.unidad.findMany.mockResolvedValue([fila({ codigo: 'U-1' })])
prismaMock.unidad.findUnique.mockResolvedValue(fila({ codigo: 'U-1' }))
const lista = await listarUnidadesConFallback()
expect(lista.map((u) => u.codigo)).toEqual(['U-1'])
expect(mondayMock.getUnidades).not.toHaveBeenCalled()
const unidad = await obtenerUnidadConFallback('U-1')
expect(unidad?.codigo).toBe('U-1')
expect(mondayMock.getUnidad).not.toHaveBeenCalled()
})
it('Monday vivo → camino normal (sin tocar local)', async () => {
mondayMock.getUnidades.mockResolvedValue([{ codigo: 'M-1' }])
mondayMock.getUnidad.mockResolvedValue({ codigo: 'M-1' })
expect(await listarUnidadesConFallback()).toEqual([{ codigo: 'M-1' }])
expect(await obtenerUnidadConFallback('M-1')).toEqual({ codigo: 'M-1' })
expect(prismaMock.unidad.findMany).not.toHaveBeenCalled()
})
it('Monday lanza AppError 502 → fallback local', async () => {
mondayMock.getUnidades.mockRejectedValue(new AppError(502, 'Monday no respondió'))
mondayMock.getUnidad.mockRejectedValue(new AppError(502, 'Monday no respondió'))
prismaMock.unidad.findMany.mockResolvedValue([fila({ codigo: 'U-1' })])
prismaMock.unidad.findUnique.mockResolvedValue(fila({ codigo: 'U-1' }))
expect((await listarUnidadesConFallback()).map((u) => u.codigo)).toEqual(['U-1'])
expect((await obtenerUnidadConFallback('U-1'))?.codigo).toBe('U-1')
})
it('otro error de Monday se propaga (no se traga nada)', async () => {
mondayMock.getUnidades.mockRejectedValue(new AppError(500, 'bug'))
await expect(listarUnidadesConFallback()).rejects.toThrow('bug')
})
})

View File

@@ -0,0 +1,100 @@
import { mondayService, type Unidad } from './monday.service.js'
import { prisma } from './prisma.js'
import { saludMonday } from './salud-monday.service.js'
import { AppError } from '../utils/errors.js'
/**
* Fallback de LECTURA del inventario (modo local): la tabla `Unidad` es el
* espejo local del tablero Unidades de Monday (la siembra
* scripts/seed-unidades-locales.js y la mantiene fresco el write-through de
* monday.service.getUnidades). Cuando Monday está caído (saludMonday) o lanza
* AppError 502, el cotizador lee de aquí con la MISMA forma de DTO.
*
* La tabla local no guarda fechaEntrega ni mesesParaFirma (no están en el
* seed curado): salen en null, que el cotizador ya tolera (plazo desconocido).
*/
/** Fila de la tabla Unidad tal como la devuelve Prisma (estructural) */
export interface FilaUnidad {
codigo: string
desarrollo: string
modelo: string
supConstruccion: number
supExcedente: number
supEstacionamiento: number
precioLista: number
precioAcabados: number
status: string
etapa: string
}
/** Fila local → DTO `Unidad` (UnidadCotizable) que consumen controllers y fronts */
export function unidadLocalADto(u: FilaUnidad): Unidad {
return {
codigo: u.codigo,
modelo: u.modelo,
fechaEntrega: null, // no está en el espejo local: plazo desconocido
supEstacionamiento: u.supEstacionamiento,
supConstruccion: u.supConstruccion,
supExcedente: u.supExcedente,
precioSinAcabados: u.precioLista,
precioConAcabados: u.precioAcabados,
status: u.status,
desarrollo: u.desarrollo,
mesesParaFirma: null,
etapa: u.etapa === '' ? null : u.etapa,
}
}
/** true si el error es un fallo de Monday (AppError 502) que amerita fallback local */
function esFalloMonday502(err: unknown): boolean {
return err instanceof AppError && err.status === 502
}
/** Unidades del espejo local; filtros equivalentes a los del controller */
export async function getUnidadesLocal(
filtro: { desarrollo?: string | undefined; soloDisponibles?: boolean | undefined } = {},
): Promise<Unidad[]> {
const rows = await prisma.unidad.findMany({ orderBy: { codigo: 'asc' } })
let lista = rows.map(unidadLocalADto)
if (filtro.soloDisponibles === true) {
lista = lista.filter((u) => u.status.trim().toUpperCase() === 'DISPONIBLE')
}
if (filtro.desarrollo !== undefined && filtro.desarrollo !== '') {
const desarrollo = filtro.desarrollo.toUpperCase()
lista = lista.filter((u) => (u.desarrollo || 'DIAGONAL').toUpperCase() === desarrollo)
}
return lista
}
/** Una unidad local por código; null si no existe en el espejo */
export async function getUnidadLocal(codigo: string): Promise<Unidad | null> {
const row = await prisma.unidad.findUnique({ where: { codigo } })
return row === null ? null : unidadLocalADto(row)
}
/**
* Lectura con fallback: Monday si está disponible; espejo local si el circuit
* breaker lo da por caído o si la llamada lanza AppError 502. Cualquier otro
* error se propaga intacto (no se traga nada del camino feliz).
*/
export async function listarUnidadesConFallback(): Promise<Unidad[]> {
if (!saludMonday.estaDisponible()) return getUnidadesLocal()
try {
return await mondayService.getUnidades()
} catch (err) {
if (esFalloMonday502(err)) return getUnidadesLocal()
throw err
}
}
/** Igual que listarUnidadesConFallback pero para una unidad por código */
export async function obtenerUnidadConFallback(codigo: string): Promise<Unidad | null> {
if (!saludMonday.estaDisponible()) return getUnidadLocal(codigo)
try {
return await mondayService.getUnidad(codigo)
} catch (err) {
if (esFalloMonday502(err)) return getUnidadLocal(codigo)
throw err
}
}