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

@@ -0,0 +1,574 @@
import { randomUUID } from 'node:crypto'
import { AppError, NotFoundError } from '../utils/errors.js'
import { claveApartadoCreado } from './automatizaciones.service.js'
import { prisma } from './prisma.js'
import type { TipoSync } from './sync-monday.service.js'
/**
* CRM local (modo degradado): implementa el embudo completo contra SQLite
* mientras Monday está caído y encola cada operación en SyncPendiente para que
* el sync worker la replique cuando Monday vuelva.
*
* - Entidad local del embudo: LeadCaptura (board LEADS | OPORTUNIDADES |
* APARTADO + etapa). Ids locales: `loc_<uuid>` (ver `esIdLocal`).
* - Las operaciones que tocan varias tablas van en $transaction; el encolado
* viaja en la MISMA transacción para que nunca quede un cambio local sin su
* réplica pendiente.
* - SECUENCIAL a propósito (SQLite P1008): nada de Promise.all con escrituras.
*
* Los controllers rutean aquí cuando `saludMonday.estaDisponible()` es false,
* cuando el id es `loc_*` (siempre local hasta que se sincronice) o como
* fallback tras un AppError 502 de Monday.
*/
/** Board local del embudo (columna LeadCaptura.board) */
export type BoardLocal = 'LEADS' | 'OPORTUNIDADES' | 'APARTADO'
/** Board local → valor del payload CAMBIO_ETAPA (boards de config.monday.boards) */
const BOARD_PAYLOAD: Record<BoardLocal, 'leads' | 'oportunidades' | 'apartado'> = {
LEADS: 'leads',
OPORTUNIDADES: 'oportunidades',
APARTADO: 'apartado',
}
/** true si el id es local (aún no existe en Monday) */
export function esIdLocal(id: string): boolean {
return id.startsWith('loc_')
}
function idLocal(): string {
return `loc_${randomUUID()}`
}
const hoyMas = (dias: number): string => {
const d = new Date()
d.setDate(d.getDate() + dias)
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
/** Captura rápida de lead (forma corta de /hoy) */
export interface DatosLeadLocal {
nombre: string
telefono?: string | undefined
email?: string | undefined
origen?: string | undefined
desarrollo?: string | undefined
notas?: string | undefined
}
/** Alta Afluencia: captura ligera + perfilamiento completo */
export interface DatosAfluenciaLocal extends DatosLeadLocal {
/** Con valor: se ACTUALIZA esa oportunidad en vez de crear una nueva */
oportunidadId?: string | undefined
modelo?: string | undefined
medioContacto?: string | undefined
edad?: number | undefined
estadoCivil?: string | undefined
tipoEmpleo?: string | undefined
ocupacion?: string | undefined
rangoSalarial?: string | undefined
credito?: string | undefined
tipoEconomia?: string | undefined
califica?: string | undefined
loQuierePara?: string | undefined
}
/** Misma forma que ResultadoCrearLead del controller (sin importarlo: ciclo) */
export type ResultadoCapturaLocal =
| { id: string; oportunidadId: string | null; actualizada?: boolean }
| { duplicado: { id: string; nombre: string } }
/** Resultado del buscador local (misma forma que ResultadoBusquedaLead) */
export interface ResultadoBusquedaLocal {
id: string
nombre: string
telefono: string
email: string
estado: string
origen: string
fuente: 'lead' | 'oportunidad'
desarrollo: string
modeloInteres: string
}
/**
* Crea un registro en la cola SyncPendiente. Es el ÚNICO punto de encolado del
* modo local: los handlers del worker (sync-handlers.ts) consumen `tipo` +
* `payloadJson` y actualizan los ids Monday usando `referenciaLocal`.
*/
export async function encolarSync(
tipo: TipoSync,
payload: Record<string, unknown>,
referenciaLocal?: string | null,
): Promise<void> {
await prisma.syncPendiente.create({
data: {
tipo,
payloadJson: JSON.stringify(payload),
referenciaLocal: referenciaLocal ?? null,
},
})
}
/**
* Versión best-effort para las réplicas que hoy solo se loguean (alta cliente,
* cotización, checkbox de documento, liquidación): un fallo al encolar NUNCA
* rompe el flujo del caller.
*/
export async function encolarSyncSeguro(
tipo: TipoSync,
payload: Record<string, unknown>,
referenciaLocal?: string | null,
): Promise<void> {
try {
await encolarSync(tipo, payload, referenciaLocal)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[sync] no se pudo encolar ${tipo}:`, err)
}
}
export class CrmLocalService {
/**
* Captura rápida local: LeadCaptura board=LEADS etapa 'NUEVO' + CREAR_LEAD
* encolado. Dedup por teléfono entre los leads locales (mismo contrato 409
* que el flujo Monday). Con usuarioId también se encola la auto-asignación
* del capturista (ASIGNAR_PERSONA) para que quede como su vendedor al
* sincronizarse.
*/
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
if (datos.telefono !== undefined) {
const existente = await prisma.leadCaptura.findFirst({
where: { telefono: datos.telefono, board: 'LEADS' },
})
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } }
}
const id = idLocal()
const datosMonday = {
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
origenLabel: datos.origen,
desarrolloLabel: datos.desarrollo,
notas: datos.notas,
}
const operaciones = [
prisma.leadCaptura.create({
data: {
leadId: id,
oportunidadId: null, // los leads no generan oportunidad
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
board: 'LEADS',
etapa: 'NUEVO',
datosJson: JSON.stringify({ notas: datos.notas ?? null }),
pendienteSync: true,
},
}),
prisma.syncPendiente.create({
data: { tipo: 'CREAR_LEAD', payloadJson: JSON.stringify({ datos: datosMonday }), referenciaLocal: id },
}),
]
if (usuarioId !== undefined) {
operaciones.push(
prisma.syncPendiente.create({
data: {
tipo: 'ASIGNAR_PERSONA',
payloadJson: JSON.stringify({ board: 'leads', usuarioId }),
referenciaLocal: id,
},
}),
)
}
await prisma.$transaction(operaciones)
return { id, oportunidadId: null }
}
/**
* Alta Afluencia local. Sin oportunidadId: LeadCaptura board=OPORTUNIDADES
* etapa 'Afluencia' con el perfilamiento en datosJson (además de los campos
* planos) + auto-mapping LeadOportunidad + CREAR_AFLUENCIA encolado. Con
* oportunidadId (botón "Afluencia" de Mis oportunidades): actualiza la fila
* local si existe y encola ACTUALIZAR_AFLUENCIA (el id puede ser de Monday).
*/
async crearAfluencia(datos: DatosAfluenciaLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
const { oportunidadId, modelo, medioContacto, edad, estadoCivil, tipoEmpleo, ocupacion } = datos
const { rangoSalarial, credito, tipoEconomia, califica, loQuierePara } = datos
const datosMonday = {
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: datos.desarrollo,
origenLabel: datos.origen,
modeloLabel: modelo,
medioContacto,
edad,
estadoCivilLabel: estadoCivil,
tipoEmpleoLabel: tipoEmpleo,
ocupacion,
rangoSalarial,
creditoLabel: credito,
tipoEconomiaLabel: tipoEconomia,
calificaLabel: califica,
loQuierePara,
notas: datos.notas,
}
if (oportunidadId !== undefined) {
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
const operaciones = []
if (existente) {
operaciones.push(
prisma.leadCaptura.update({
where: { leadId: oportunidadId },
data: {
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null,
etapa: 'Afluencia',
datosJson: JSON.stringify(datos),
pendienteSync: true,
},
}),
)
}
operaciones.push(
prisma.syncPendiente.create({
data: {
tipo: 'ACTUALIZAR_AFLUENCIA',
payloadJson: JSON.stringify({ oportunidadId, datos: datosMonday }),
referenciaLocal: oportunidadId,
},
}),
)
await prisma.$transaction(operaciones)
return { id: oportunidadId, oportunidadId, actualizada: true }
}
if (datos.telefono !== undefined) {
const duplicado = await prisma.leadCaptura.findFirst({
where: { telefono: datos.telefono, board: 'OPORTUNIDADES' },
})
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
}
const id = idLocal()
const operaciones = [
prisma.leadCaptura.create({
data: {
leadId: id,
oportunidadId: id, // la afluencia ES la oportunidad
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null,
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
datosJson: JSON.stringify(datos),
pendienteSync: true,
},
}),
// Auto-mapping leadId = oportunidadId (como el flujo Monday): el alta de
// clientes encuentra así la oportunidad a mover a 'Aparto'
prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }),
prisma.syncPendiente.create({
data: { tipo: 'CREAR_AFLUENCIA', payloadJson: JSON.stringify({ datos: datosMonday }), referenciaLocal: id },
}),
]
if (usuarioId !== undefined) {
operaciones.push(
prisma.syncPendiente.create({
data: {
tipo: 'ASIGNAR_PERSONA',
payloadJson: JSON.stringify({ board: 'oportunidades', usuarioId }),
referenciaLocal: id,
},
}),
)
}
await prisma.$transaction(operaciones)
return { id, oportunidadId: id }
}
/**
* Fila local del item, creándola "sombra" si no existe y `sombra` es true.
* Las sombras cubren el fallback 502 sobre ids de Monday antiguos (creados
* antes del modo local): la acción queda reflejada en Mi día y el cambio se
* encola igual. Sin `sombra` → 404.
*/
private async filaLocal(itemId: string, board: BoardLocal, usuarioId: string | undefined, sombra: boolean) {
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
if (row) return row
if (!sombra) throw new NotFoundError(`No existe registro local para el item ${itemId}`)
return prisma.leadCaptura.create({
data: {
leadId: itemId,
usuarioId: usuarioId ?? 'desconocido',
nombreLead: 'Registro previo (Monday)',
board,
etapa: 'NUEVO',
mondayItemId: esIdLocal(itemId) ? null : itemId,
pendienteSync: true,
},
})
}
/**
* Cambio de estado/etapa local (boards LEADS, OPORTUNIDADES y APARTADO):
* actualiza `etapa` (+updatedAt vía Prisma) y encola CAMBIO_ETAPA con el
* label real de la columna 'Estado' (y la fecha de la cita si aplica).
*/
async cambiarEstado(
itemId: string,
label: string,
board: BoardLocal,
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
): Promise<void> {
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
const payload: Record<string, unknown> = { board: BOARD_PAYLOAD[board], label }
if (opciones.fecha !== undefined) payload['fecha'] = opciones.fecha
await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: itemId },
data: { etapa: label, pendienteSync: true },
}),
prisma.syncPendiente.create({
data: { tipo: 'CAMBIO_ETAPA', payloadJson: JSON.stringify(payload), referenciaLocal: itemId },
}),
])
}
/**
* Descarte local: etapa terminal ('Descartado' en Leads, 'Perdido' en
* Oportunidades), guarda motivo/submotivo y encola DESCARTE (el handler
* replica el estado + el item del tablero Motivos No Compra).
*/
async descartar(
itemId: string,
motivo: string,
opciones: {
fuente?: 'lead' | 'oportunidad' | undefined
subcategoria?: string | undefined
comentario?: string | undefined
usuarioId?: string | undefined
sombra?: boolean | undefined
} = {},
): Promise<void> {
const fuente = opciones.fuente ?? 'oportunidad'
const board: BoardLocal = fuente === 'lead' ? 'LEADS' : 'OPORTUNIDADES'
const row = await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
const etapaTerminal = fuente === 'lead' ? 'Descartado' : 'Perdido'
const payload: Record<string, unknown> = { fuente, motivo, nombreItem: row.nombreLead }
if (opciones.subcategoria !== undefined) payload['subcategoria'] = opciones.subcategoria
if (opciones.comentario !== undefined) payload['comentario'] = opciones.comentario
await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: itemId },
data: {
etapa: etapaTerminal,
motivo,
submotivo: opciones.subcategoria ?? null,
pendienteSync: true,
},
}),
prisma.syncPendiente.create({
data: { tipo: 'DESCARTE', payloadJson: JSON.stringify(payload), referenciaLocal: itemId },
}),
])
}
/**
* Conversión local lead → oportunidad (botón "Agendo" de Mis leads): el lead
* queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando
* los datos, se liga LeadOportunidad + `oportunidadId` en la captura y se
* encola CONVERSION. Si el lead ya fue convertido → 409 (como el flujo
* Monday). Devuelve la fila local de la oportunidad.
*/
async convertirLead(leadIdLocal: string, _usuarioId?: string) {
const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } })
if (!lead) throw new NotFoundError('Lead no encontrado')
if (lead.oportunidadId !== null) throw new AppError(409, 'El lead ya fue convertido en oportunidad')
const oportunidadId = idLocal()
const datosOportunidad = {
nombre: lead.nombreLead,
estadoLabel: 'Agendo Cita',
telefono: lead.telefono ?? undefined,
email: lead.email ?? undefined,
origenLabel: lead.origen ?? undefined,
desarrolloLabel: lead.desarrollo ?? undefined,
modeloLabel: lead.modelo ?? undefined,
}
const resultados = await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: leadIdLocal },
data: { etapa: 'Calificado', oportunidadId, pendienteSync: true },
}),
prisma.leadCaptura.create({
data: {
leadId: oportunidadId,
oportunidadId,
usuarioId: lead.usuarioId,
nombreLead: lead.nombreLead,
telefono: lead.telefono,
email: lead.email,
origen: lead.origen,
desarrollo: lead.desarrollo,
modelo: lead.modelo,
board: 'OPORTUNIDADES',
etapa: 'Agendo Cita',
datosJson: lead.datosJson,
pendienteSync: true,
},
}),
prisma.leadOportunidad.create({ data: { leadId: leadIdLocal, oportunidadId } }),
prisma.syncPendiente.create({
data: {
tipo: 'CONVERSION',
payloadJson: JSON.stringify({ oportunidadIdLocal: oportunidadId, datos: datosOportunidad }),
referenciaLocal: leadIdLocal,
},
}),
])
return resultados[1]
}
/**
* "Aparto" local: la oportunidad pasa a etapa 'Aparto' y se crea el apartado
* local (board=APARTADO, etapa 'Pago Apartado', primer paso del pipeline
* Enkontrol) con los datos conocidos. Idempotente con el mismo dedup
* `apartado-creado:<opId>` que las automatizaciones. Encola CAMBIO_ETAPA +
* CREAR_APARTADO (en ese orden: la etapa primero, como en Monday).
*/
async apartar(oportunidadIdLocal: string, usuarioId?: string) {
// Sombra para ids Monday sin fila local (fallback 502 sobre items viejos)
const oportunidad = await this.filaLocal(
oportunidadIdLocal,
'OPORTUNIDADES',
usuarioId,
!esIdLocal(oportunidadIdLocal),
)
const clave = claveApartadoCreado(oportunidadIdLocal)
const previo = await prisma.automatizacionState.findUnique({ where: { clave } })
if (previo !== null) {
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: previo.valor } })
if (existente) return existente
}
const apartadoId = idLocal()
const datosApartado = {
clienteNombre: oportunidad.nombreLead,
unidadCodigo: '', // la unidad se captura en la cotización/alta
fechaApartado: hoyMas(0),
fechaLimite: hoyMas(15),
estadoLabel: 'Pago Apartado',
telefono: oportunidad.telefono ?? undefined,
email: oportunidad.email ?? undefined,
}
const resultados = await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: oportunidadIdLocal },
data: { etapa: 'Aparto', pendienteSync: true },
}),
prisma.leadCaptura.create({
data: {
leadId: apartadoId,
usuarioId: oportunidad.usuarioId,
nombreLead: oportunidad.nombreLead,
telefono: oportunidad.telefono,
email: oportunidad.email,
desarrollo: oportunidad.desarrollo,
modelo: oportunidad.modelo,
board: 'APARTADO',
etapa: 'Pago Apartado',
datosJson: oportunidad.datosJson,
pendienteSync: true,
},
}),
prisma.automatizacionState.upsert({
where: { clave },
update: { valor: apartadoId },
create: { clave, valor: apartadoId },
}),
prisma.syncPendiente.create({
data: {
tipo: 'CAMBIO_ETAPA',
payloadJson: JSON.stringify({ board: 'oportunidades', label: 'Aparto' }),
referenciaLocal: oportunidadIdLocal,
},
}),
prisma.syncPendiente.create({
data: {
tipo: 'CREAR_APARTADO',
payloadJson: JSON.stringify({ datos: datosApartado }),
referenciaLocal: apartadoId,
},
}),
])
return resultados[1]
}
/**
* Búsqueda local para el picker del alta de clientes (y el buscador del
* cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular,
* con la misma forma de respuesta que la búsqueda Monday.
*/
async buscarLocales(termino: string): Promise<ResultadoBusquedaLocal[]> {
const capturas = await prisma.leadCaptura.findMany({
where: { OR: [{ nombreLead: { contains: termino } }, { telefono: { contains: termino } }] },
orderBy: { updatedAt: 'desc' },
take: 15,
})
const clientes = await prisma.cliente.findMany({
where: { OR: [{ nombreCompleto: { contains: termino } }, { celular: { contains: termino } }] },
orderBy: { createdAt: 'desc' },
take: 15,
})
const candidatos = [
...capturas.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
estado: c.etapa,
origen: c.origen ?? '',
fuente: (c.board === 'LEADS' ? 'lead' : 'oportunidad') as 'lead' | 'oportunidad',
desarrollo: c.desarrollo ?? '',
modeloInteres: c.modelo ?? '',
orden: c.updatedAt,
})),
...clientes.map((c) => ({
id: c.id,
nombre: c.nombreCompleto,
telefono: c.celular ?? '',
email: c.email ?? '',
estado: '',
origen: '',
fuente: 'oportunidad' as const,
desarrollo: c.proyecto,
modeloInteres: '',
orden: c.createdAt,
})),
]
return candidatos
.sort((a, b) => b.orden.getTime() - a.orden.getTime())
.slice(0, 15)
.map(({ orden: _orden, ...resto }) => resto)
}
}
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
export const crmLocal = new CrmLocalService()