feat(modo-local): fallback completo sin Monday + cola de sincronización

- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes

Env MODO_LOCAL_FORZADO=true para testing sin Monday.
This commit is contained in:
2026-09-06 08:08:56 +00:00
parent af2fb10956
commit 60649890eb
70 changed files with 8763 additions and 240 deletions

View File

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