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