14 servicios eliminados, 7 controllers local-only, crons locales, config sin MONDAY_*, frontend sin banners/avisos Monday. 552 tests API + 7 apps construidas.
301 lines
10 KiB
TypeScript
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')
|
|
}
|