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