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:
271
api-gateway/src/services/reportes-local.test.ts
Normal file
271
api-gateway/src/services/reportes-local.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user