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,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 ----------
|
||||
|
||||
Reference in New Issue
Block a user