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

@@ -4,6 +4,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 { automatizacionesService } from '../services/automatizaciones.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
export const MOTIVOS_NO_COMPRA = [
@@ -69,7 +72,29 @@ export async function perderOportunidad(
export async function perderOportunidadEndpoint(req: Request, res: Response): Promise<void> {
const body = perderSchema.parse(req.body)
const mondayItemId = String(req.params['mondayItemId'])
const motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
let motivoItemId: string | null = null
// Modo local (id loc_* o Monday caído / fallback 502): descarte local +
// DESCARTE encolado; el motivo del tablero catálogo lo crea el worker
if (esIdLocal(mondayItemId) || !saludMonday.estaDisponible()) {
await crmLocal.descartar(mondayItemId, body.motivo, {
fuente: 'oportunidad',
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(mondayItemId),
})
} else {
try {
motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.descartar(mondayItemId, body.motivo, {
fuente: 'oportunidad',
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(mondayItemId),
})
}
}
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
}
@@ -123,21 +148,55 @@ export async function cambiarEtapaOportunidad(
await moverAGrupoSegunEstado(monday, boardOportunidades, itemId)
}
/**
* Cambio de etapa en modo local: guarda la etapa en la fila local y encola
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar),
* el equivalente al asegurarApartado del flujo Monday.
*/
async function cambiarEtapaLocal(
itemId: string,
etapa: (typeof ETAPAS_OPORTUNIDAD_PERMITIDAS)[number],
fecha: string | undefined,
usuarioId: string | undefined,
): Promise<void> {
if (etapa === 'Aparto') {
await crmLocal.apartar(itemId, usuarioId)
return
}
await crmLocal.cambiarEstado(itemId, etapa, 'OPORTUNIDADES', {
fecha,
usuarioId,
sombra: !esIdLocal(itemId),
})
}
/** POST /api/oportunidades/:itemId/etapa { etapa, fecha? } */
export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEtapaSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
// En 'Aparto' el apartado debe existir YA (Mis apartados recarga al volver):
// asegurarApartado reintenta unos segundos (Monday tarda en propagar la
// etapa) y es idempotente (dedup); el webhook queda como red de seguridad
// para cambios hechos directo en Monday.
if (body.etapa === 'Aparto') {
const usuarioSub = req.usuarioToken?.sub
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub)
} else {
try {
await automatizacionesService.asegurarApartado(itemId)
await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear el apartado de la oportunidad ${itemId}:`, err)
if (!esFalloMonday(err)) throw err
await cambiarEtapaLocal(itemId, body.etapa, body.fecha, usuarioSub)
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
return
}
// En 'Aparto' el apartado debe existir YA (Mis apartados recarga al volver):
// asegurarApartado reintenta unos segundos (Monday tarda en propagar la
// etapa) y es idempotente (dedup); el webhook queda como red de seguridad
// para cambios hechos directo en Monday.
if (body.etapa === 'Aparto') {
try {
await automatizacionesService.asegurarApartado(itemId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo crear el apartado de la oportunidad ${itemId}:`, err)
}
}
}
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })