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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user