Files
FRASA-CAS/api-gateway/src/services/contactos-local.service.ts
Consult AS Technology 89d95eb53b feat(purga): Monday eliminado completamente del sistema
14 servicios eliminados, 7 controllers local-only, crons locales,
config sin MONDAY_*, frontend sin banners/avisos Monday.
552 tests API + 7 apps construidas.
2026-09-11 11:47:17 +00:00

301 lines
10 KiB
TypeScript

import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js'
import { prisma } from './prisma.js'
/**
* Módulo Contactos 100% local: la lista y el detalle se arman desde las
* tablas locales (Cliente + LeadCaptura + Documento + Cotizacion). 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 interface ContactoListaLocal {
id: string
nombre: string
telefono: string
email: string
estado: string
updatedAt: string
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).
* Filtra por usuario actual: clientes capturados por él + leads asignados a él.
*/
export async function listarContactosLocal(usuarioId?: string): Promise<ContactoListaLocal[]> {
const clientes = await prisma.cliente.findMany({
where: usuarioId ? { capturadoPorId: usuarioId } : {},
orderBy: { createdAt: 'desc' },
take: 100,
})
const capturas = await prisma.leadCaptura.findMany({
where: {
board: { in: ['OPORTUNIDADES', 'APARTADO'] },
...(usuarioId ? { usuarioId } : {}),
},
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
* heredado) o mondayContactoId (compatibilidad de datos). Las relaciones se
* arman por teléfono (últimos 10 dígitos) o email sobre LeadCaptura; el
* historial se deriva de capturas + alta + documentos + cotizaciones locales.
* 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,
telefono: cliente.celular ?? '',
email: cliente.email ?? '',
estado: 'Cliente',
curp: cliente.curp,
rfc: cliente.rfc,
}
: {
nombre: capturaBase!.nombreLead,
telefono: capturaBase!.telefono ?? '',
email: capturaBase!.email ?? '',
estado: capturaBase!.etapa,
}
return { contacto, propiedad, relaciones, historial }
}
// ── Exportar contactos a CSV ─────────────────────────────────────────
/**
* Genera CSV de todos los contactos (Cliente + LeadCaptura).
* Columnas: Nombre, Teléfono, Email, Desarrollo, Estatus, Última actividad.
*/
export async function exportarCSV(filtros: { desarrollo?: string; estatus?: string } = {}): Promise<string> {
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' } })
const capturas = await prisma.leadCaptura.findMany({
where: {
board: { in: ['OPORTUNIDADES', 'APARTADO', 'LEADS'] },
},
orderBy: { updatedAt: 'desc' },
})
interface FilaCSV {
Nombre: string
Teléfono: string
Email: string
Desarrollo: string
Estatus: string
'Última actividad': string
}
const filas: FilaCSV[] = []
const vistos = new Set<string>()
for (const c of clientes) {
const clave = (c.celular ?? '').replace(/\D/g, '').slice(-10)
if (clave !== '' && vistos.has(clave)) continue
if (clave !== '') vistos.add(clave)
filas.push({
Nombre: c.nombreCompleto,
Teléfono: c.celular ?? '',
Email: c.email ?? '',
Desarrollo: c.proyecto,
Estatus: 'Cliente',
'Última actividad': c.createdAt.toISOString().slice(0, 10),
})
}
for (const cap of capturas) {
const clave = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
if (clave !== '' && vistos.has(clave)) continue
if (clave !== '') vistos.add(clave)
filas.push({
Nombre: cap.nombreLead,
Teléfono: cap.telefono ?? '',
Email: cap.email ?? '',
Desarrollo: cap.desarrollo ?? '',
Estatus: cap.etapa,
'Última actividad': cap.updatedAt.toISOString().slice(0, 10),
})
}
// Aplicar filtros
let filtradas = filas
if (filtros.desarrollo) {
const d = filtros.desarrollo.toUpperCase()
filtradas = filtradas.filter((f) => f.Desarrollo.toUpperCase().includes(d))
}
if (filtros.estatus) {
const e = filtros.estatus.toUpperCase()
filtradas = filtradas.filter((f) => f.Estatus.toUpperCase().includes(e))
}
// Generar CSV
const encabezado = 'Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad'
const escape = (s: string) => (s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s)
const lineas = filtradas.map((f) =>
[f.Nombre, f.Teléfono, f.Email, f.Desarrollo, f.Estatus, f['Última actividad']].map(escape).join(',')
)
return [encabezado, ...lineas].join('\n')
}