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,211 @@
import type { ContactoMonday } from './monday.service.js'
import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js'
import { prisma } from './prisma.js'
/**
* Fallback de LECTURA del módulo Contactos (modo local): cuando Monday está
* caído, la lista y el detalle se arman desde las tablas locales (Cliente +
* LeadCaptura + Documento + Cotizacion) con la MISMA forma de DTO que el
* camino Monday. Lo que no existe en local (activity log, columnas del board)
* va vacío; el historial se deriva de las capturas/altas/cotizaciones locales.
*/
/** Contacto de la lista con flag aditivo de procedencia (el front lo ignora o lo muestra) */
export type ContactoListaLocal = ContactoMonday & { fuente?: 'local' }
/**
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards
* OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la
* ficha completa), más reciente primero, máx 100 (como getContactos).
*/
export async function listarContactosLocal(): Promise<ContactoListaLocal[]> {
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' }, take: 100 })
const capturas = await prisma.leadCaptura.findMany({
where: { board: { in: ['OPORTUNIDADES', 'APARTADO'] } },
orderBy: { updatedAt: 'desc' },
take: 100,
})
const vistos = new Set<string>()
const lista: ContactoListaLocal[] = []
const agregar = (entrada: ContactoListaLocal): void => {
const clave = claveTelefono(entrada.telefono)
if (clave !== '') {
if (vistos.has(clave)) return
vistos.add(clave)
}
lista.push(entrada)
}
for (const c of clientes) {
agregar({
id: c.id,
nombre: c.nombreCompleto,
telefono: c.celular ?? '',
email: c.email ?? '',
estado: c.proyecto.trim() !== '' ? 'Cliente' : '',
updatedAt: c.createdAt.toISOString(),
fuente: 'local',
})
}
for (const c of capturas) {
agregar({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
estado: c.etapa,
updatedAt: c.updatedAt.toISOString(),
fuente: 'local',
})
}
return lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 100)
}
/**
* Detalle local del contacto. El id puede ser cliente.id, leadId (loc_* o id
* Monday sombra) o mondayContactoId. Las relaciones se arman por teléfono
* (últimos 10 dígitos) o email sobre LeadCaptura, igual que el camino Monday;
* el historial se deriva de capturas + alta + documentos + cotizaciones
* locales (no hay activity log en local). null si no existe en ninguna tabla.
*/
export async function detalleContactoLocal(contactoId: string): Promise<DetalleContacto | null> {
let cliente = await prisma.cliente.findFirst({
where: { OR: [{ id: contactoId }, { mondayContactoId: contactoId }] },
})
const capturaBase = cliente
? null
: await prisma.leadCaptura.findUnique({ where: { leadId: contactoId } })
if (cliente === null && capturaBase === null) return null
const telefono = cliente?.celular ?? capturaBase?.telefono ?? ''
const email = (cliente?.email ?? capturaBase?.email ?? '').trim().toLowerCase()
const clave = claveTelefono(telefono)
// Si el id era de una captura, el Cliente ligado se busca por celular
if (cliente === null && clave !== '') {
cliente = await prisma.cliente.findFirst({
where: { celular: { contains: clave } },
orderBy: { createdAt: 'desc' },
})
}
const capturasTodas = await prisma.leadCaptura.findMany({ orderBy: { createdAt: 'desc' } })
const relacionadas = capturasTodas.filter(
(c) =>
(clave !== '' && c.telefono !== null && claveTelefono(c.telefono) === clave) ||
(email !== '' && (c.email ?? '').trim().toLowerCase() === email),
)
// Cotizaciones y documentos locales del contacto
const cotizaciones =
clave !== ''
? await prisma.cotizacion.findMany({
where: { clienteTelefono: { contains: clave } },
orderBy: { createdAt: 'desc' },
})
: []
const documentos = cliente
? await prisma.documento.findMany({ where: { clienteId: cliente.id }, orderBy: { createdAt: 'desc' } })
: []
// Nombres de capturistas/vendedores (ids locales → nombre para mostrar)
const idsUsuarios = [
...new Set([
...relacionadas.map((c) => c.usuarioId),
...(cliente?.capturadoPorId ? [cliente.capturadoPorId] : []),
...cotizaciones.map((c) => c.vendedorId).filter((id): id is string => id !== null),
]),
]
const usuarios = idsUsuarios.length
? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } })
: []
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
const nombreDe = (id: string | null): string | null => (id !== null ? (nombrePorId.get(id) ?? null) : null)
const unidadCliente = cliente?.unidadCodigo ?? cotizaciones[0]?.unidadCodigo ?? ''
const aRelacion = (c: (typeof relacionadas)[number], detalle: string): RelacionCrm => ({
id: c.leadId,
nombre: c.nombreLead,
estado: c.etapa,
atendidoPor: nombreDe(c.usuarioId),
detalle,
})
const relaciones = {
leads: relacionadas
.filter((c) => c.board === 'LEADS')
.map((c) => aRelacion(c, c.origen ?? '')),
oportunidades: relacionadas
.filter((c) => c.board === 'OPORTUNIDADES')
.map((c) => aRelacion(c, [c.desarrollo, c.modelo].filter((p) => p !== null && p !== '').join(' · '))),
apartados: relacionadas
.filter((c) => c.board === 'APARTADO')
.map((c) => aRelacion(c, c.leadId === cliente?.mondayApartadoId ? unidadCliente : '')),
}
// Propiedad: ficha del Cliente (la comprada o en proceso); sin Cliente no hay
let propiedad: DetalleContacto['propiedad'] = null
if (cliente !== null) {
const apartadoLocal = relacionadas.find((c) => c.leadId === cliente.mondayApartadoId)
propiedad = {
id: cliente.mondayApartadoId ?? cliente.id,
unidadCodigo: unidadCliente,
desarrollo: cliente.proyecto,
estado: apartadoLocal?.etapa ?? '',
precioFinal: cotizaciones[0]?.precioFinal ?? 0,
fechaCompra: cliente.createdAt.toISOString().slice(0, 10),
fechaLimite: '',
atendidoPor: nombreDe(cliente.capturadoPorId),
}
}
const historial: EventoHistorialContacto[] = [
...relacionadas.map((c) => ({
fechaISO: c.createdAt.toISOString(),
usuario: nombreDe(c.usuarioId),
detalle: `Captura en la app (${c.nombreLead})`,
fuente: 'App',
})),
...(cliente !== null
? [
{
fechaISO: cliente.createdAt.toISOString(),
usuario: nombreDe(cliente.capturadoPorId),
detalle: `Alta de cliente registrada${cliente.unidadCodigo !== null ? ` (${cliente.unidadCodigo})` : ''}`,
fuente: 'App',
},
]
: []),
...documentos.map((d) => ({
fechaISO: d.createdAt.toISOString(),
usuario: null,
detalle: `Documento subido (${d.tipo})`,
fuente: 'App',
})),
...cotizaciones.map((c) => ({
fechaISO: c.createdAt.toISOString(),
usuario: nombreDe(c.vendedorId),
detalle: `Cotización ${c.unidadCodigo} (${c.tipoPropuesta})`,
fuente: 'App',
})),
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
const contacto: Record<string, string> =
cliente !== null
? {
nombre: cliente.nombreCompleto,
Teléfono: cliente.celular ?? '',
Email: cliente.email ?? '',
CURP: cliente.curp,
RFC: cliente.rfc,
Estado: 'Cliente',
}
: {
nombre: capturaBase!.nombreLead,
Teléfono: capturaBase!.telefono ?? '',
Email: capturaBase!.email ?? '',
Estado: capturaBase!.etapa,
}
return { contacto, propiedad, relaciones, historial }
}