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