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:
211
api-gateway/src/services/contactos-local.service.ts
Normal file
211
api-gateway/src/services/contactos-local.service.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user