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

@@ -5,6 +5,9 @@ import { mondayService, type LeadMonday, type MondayService, type OportunidadMon
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.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 { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
import { NotFoundError } from '../utils/errors.js'
@@ -402,7 +405,8 @@ export async function descartarLead(
/**
* Self-assign del capturista con bandera anti-carrera rr:skip (para que el
* webhook create_item no lo reparta por round-robin mientras Monday propaga la
* escritura; si la asignación falla, se apaga y cae al reparto).
* escritura; si la asignación falla, se apaga y cae al reparto — y queda
* encolada como ASIGNAR_PERSONA para reintentarla cuando Monday vuelva).
* Devuelve cómo quedó la asignación.
*/
async function asignarCapturistaSiAplica(
@@ -424,6 +428,8 @@ async function asignarCapturistaSiAplica(
return 'capturista'
} catch (err) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'round-robin')
// Best-effort: el worker la reintenta al drenar la cola
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: capturista.mondayUserId }, itemId)
throw err
}
}
@@ -437,7 +443,23 @@ async function asignarCapturistaSiAplica(
/** POST /api/leads { nombre, telefono?, email?, origen?, desarrollo?, notas? } — captura rápida (tablero Leads) */
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = crearLeadSchema.parse(req.body)
const resultado = await crearLead(mondayService, body)
const usuarioSub = req.usuarioToken?.sub
// Modo local: Monday caído (flag) → directo a crmLocal; Monday vivo → flujo
// actual con fallback a local si la llamada lanza 502. La respuesta mantiene
// la misma forma en ambos caminos (el front no se entera).
let modoLocal = !saludMonday.estaDisponible()
let resultado: ResultadoCrearLead
if (modoLocal) {
resultado = await crmLocal.crearLead(body, usuarioSub)
} else {
try {
resultado = await crearLead(mondayService, body)
} catch (err) {
if (!esFalloMonday(err)) throw err
modoLocal = true
resultado = await crmLocal.crearLead(body, usuarioSub)
}
}
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
@@ -446,16 +468,27 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
})
return
}
if (modoLocal) {
// Sin round-robin en modo local: el capturista queda asignado (su
// ASIGNAR_PERSONA viaja encolada tras el CREATE)
res.status(201).json({
id: resultado.id,
estado: 'NUEVO',
oportunidadId: null,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día (Monday
// no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta:
// el lead ya quedó creado en Monday.
if (req.usuarioToken?.sub) {
if (usuarioSub) {
try {
await prisma.leadCaptura.create({
data: {
leadId: resultado.id,
oportunidadId: null, // los leads ya no generan oportunidad
usuarioId: req.usuarioToken.sub,
usuarioId: usuarioSub,
nombreLead: body.nombre,
telefono: body.telefono ?? null,
},
@@ -465,14 +498,29 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
}
}
const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'leads')
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'leads')
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion })
}
/** POST /api/afluencias { nombre, telefono?, ..., perfil completo } — Alta Afluencia (tablero Oportunidades) */
export async function crearAfluenciaEndpoint(req: Request, res: Response): Promise<void> {
const body = crearAfluenciaSchema.parse(req.body)
const resultado = await crearAfluencia(mondayService, body)
const usuarioSub = req.usuarioToken?.sub
// Modo local: Monday caído (flag) → crmLocal (crear o actualizar); fallback
// a local tras 502. Misma forma de respuesta en ambos caminos.
let modoLocal = !saludMonday.estaDisponible()
let resultado: ResultadoCrearLead
if (modoLocal) {
resultado = await crmLocal.crearAfluencia(body, usuarioSub)
} else {
try {
resultado = await crearAfluencia(mondayService, body)
} catch (err) {
if (!esFalloMonday(err)) throw err
modoLocal = true
resultado = await crmLocal.crearAfluencia(body, usuarioSub)
}
}
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
@@ -487,16 +535,25 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
return
}
if (modoLocal) {
res.status(201).json({
id: resultado.id,
estado: 'Afluencia',
oportunidadId: resultado.oportunidadId,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día.
// leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un
// fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday.
if (req.usuarioToken?.sub) {
if (usuarioSub) {
try {
await prisma.leadCaptura.create({
data: {
leadId: resultado.id,
oportunidadId: resultado.oportunidadId,
usuarioId: req.usuarioToken.sub,
usuarioId: usuarioSub,
nombreLead: body.nombre,
telefono: body.telefono ?? null,
},
@@ -517,15 +574,51 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
// eslint-disable-next-line no-console
console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err)
}
const asignacion = await asignarCapturistaSiAplica(req.usuarioToken?.sub, resultado.id, body.nombre, 'oportunidades')
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'oportunidades')
res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion })
}
/**
* Cambio de estado en modo local: guarda la etapa (label real del board) en la
* fila local y encola CAMBIO_ETAPA. Con id Monday sin fila local (fallback 502
* sobre items viejos) crea la fila "sombra" para que Mi día lo refleje.
*/
async function cambiarEstadoLocal(
itemId: string,
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
fecha: string | undefined,
fuente: FuenteLead,
usuarioId: string | undefined,
): Promise<void> {
const esLead = fuente === 'lead'
if (estado === 'INTERESADO' && !esLead) {
throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads')
}
const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado]
await crmLocal.cambiarEstado(itemId, label, esLead ? 'LEADS' : 'OPORTUNIDADES', {
fecha,
usuarioId,
sombra: !esIdLocal(itemId),
})
}
/** POST /api/leads/:itemId/estado { estado, fecha?, fuente? } */
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente)
const fuente = body.fuente ?? 'oportunidad'
// Los ids loc_* siempre van al path local (aún no existen en Monday); con el
// flag caído todo va a local; con Monday vivo, fallback a local tras 502.
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub)
} else {
try {
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente)
} catch (err) {
if (!esFalloMonday(err)) throw err
await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub)
}
}
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
}
@@ -533,7 +626,30 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = descartarSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria)
const fuente = body.fuente ?? 'oportunidad'
let motivoItemId: string | null = null
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await crmLocal.descartar(itemId, body.motivo, {
fuente,
subcategoria: body.subcategoria,
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
} else {
try {
motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.descartar(itemId, body.motivo, {
fuente,
subcategoria: body.subcategoria,
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
}
}
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
}
@@ -618,18 +734,39 @@ export async function convertirLead(
export async function convertirLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = convertirSchema.parse(req.body ?? {})
const itemId = String(req.params['itemId'])
const { oportunidadId } = await convertirLead(mondayService, itemId, req.usuarioToken?.sub, body.fecha)
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
const usuarioSub = req.usuarioToken?.sub
// Modo local (id loc_* o Monday caído / fallback 502): la conversión crea la
// oportunidad local en 'Agendo Cita' y encola CONVERSION. Nota: en el
// fallback la fecha de la cita viaja en el CAMBIO_ETAPA posterior del front,
// como en el flujo normal.
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
return
}
try {
const { oportunidadId } = await convertirLead(mondayService, itemId, usuarioSub, body.fecha)
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
} catch (err) {
if (!esFalloMonday(err)) throw err
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
}
}
/**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2
* caracteres; devuelve máximo 15, cada uno con su `fuente`.
* Con Monday caído busca en lo local (LeadCaptura + Cliente), misma forma.
*/
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
const q = String(req.query['q'] ?? '').trim()
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
if (!saludMonday.estaDisponible()) {
res.json(await crmLocal.buscarLocales(q))
return
}
const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()])
res.json(buscarLeads(leads, oportunidades, q))
}