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