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

@@ -8,6 +8,9 @@ import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { prisma } from '../services/prisma.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { checkboxParaTipoDocumento } from './clientes.controller.js'
/**
@@ -54,7 +57,24 @@ export async function cambiarEstadoApartado(
export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await cambiarEstadoApartado(mondayService, itemId, body.estado)
// Modo local (id loc_* o Monday caído / fallback 502): fase en la fila local
// + CAMBIO_ETAPA encolado; ids Monday viejos sin fila crean sombra
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
} else {
try {
await cambiarEstadoApartado(mondayService, itemId, body.estado)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
usuarioId: req.usuarioToken?.sub,
sombra: true,
})
}
}
res.json({ itemId, estado: body.estado })
}
@@ -104,12 +124,28 @@ export const uploadDocumentosApartado = multer({
/**
* Cliente local ligado al apartado de Monday; si aún no existe (apartado
* creado sin pasar por el alta progresiva), se crea la ficha mínima para que
* los documentos tengan dónde vivir.
* los documentos tengan dónde vivir. El nombre se toma de Monday; si Monday
* está caído o el id es local (loc_*), se usa el de la fila LeadCaptura del
* apartado (board APARTADO, creada por el "Aparto" local) como fallback.
*/
async function clienteDeApartado(itemId: string, capturadoPorId?: string) {
const existente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } })
if (existente) return existente
const nombre = await mondayService.obtenerNombreItem(itemId)
let nombre: string | null = null
if (!esIdLocal(itemId) && saludMonday.estaDisponible()) {
try {
nombre = await mondayService.obtenerNombreItem(itemId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo leer el nombre del apartado ${itemId} en Monday; se usa el nombre local:`, err)
}
}
if (nombre === null) {
const captura = await prisma.leadCaptura.findFirst({
where: { OR: [{ leadId: itemId }, { mondayItemId: itemId }] },
})
nombre = captura?.nombreLead ?? 'Cliente'
}
return prisma.cliente.create({
data: {
nombreCompleto: nombre,
@@ -151,14 +187,20 @@ export async function subirDocumentosApartado(req: Request, res: Response): Prom
)
// Prende el checkbox del documento en el item de Apartado (si el tipo tiene
// checkbox); un fallo de Monday no rompe la subida.
// checkbox); un fallo de Monday no rompe la subida: queda encolado
// (CHECKBOX_DOC) y el worker lo aplica al sincronizar.
const checkbox = checkboxParaTipoDocumento(tipo)
if (checkbox !== null) {
try {
await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err)
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId)
} else {
try {
await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err)
await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId)
}
}
}
res.status(201).json(documentos)