feat(contactos): detalle completo del cliente con propiedad e historial
- GET /api/contactos/:id/detalle: todas las columnas del contacto, la propiedad comprada/en proceso (apartado relacionado por teléfono/email, enriquecido con el desarrollo de la unidad), quién atiende cada item relacionado (leads/oportunidades/apartados, columna people) y el historial completo de atención (activity log de los 4 tableros + capturas y alta local con nombre del capturista) - monday.service: getContactoDetalle (columnas por título), getHistorialItems (todos los eventos del activity log por items) y ApartadoMonday enriquecido (teléfono, email, precio final, fecha compra) - Landing: la lista de Contactos abre un modal por cliente con datos, propiedad, relaciones CRM (con quién atiende) y timeline de historial - Tests: 465 API + 12 landing, verde
This commit is contained in:
197
api-gateway/src/services/contactos-detalle.service.ts
Normal file
197
api-gateway/src/services/contactos-detalle.service.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { config } from '../config/index.js'
|
||||
import { detalleEventoUltimoCambio, esEstadoFinalApartado } from './mi-dia.service.js'
|
||||
import type { MondayService } from './monday.service.js'
|
||||
|
||||
/**
|
||||
* Detalle del módulo Contactos: toda la información del contacto, la propiedad
|
||||
* comprada (apartado relacionado), quién lo atiende (asesor/vendedor actual de
|
||||
* cada item relacionado) y el historial completo de atención.
|
||||
*
|
||||
* Las relaciones se arman por teléfono (últimos 10 dígitos) o email a través de
|
||||
* los tableros Leads, Oportunidades y Apartado; el historial mezcla el activity
|
||||
* log de Monday de esos items + el registro local (quién capturó qué en las apps).
|
||||
*/
|
||||
|
||||
export interface RelacionCrm {
|
||||
id: string
|
||||
nombre: string
|
||||
estado: string // estado/etapa actual
|
||||
/** Quién lo atiende ahora (columna people del tablero); null si sin asignar */
|
||||
atendidoPor: string | null
|
||||
detalle: string // origen/desarrollo/unidad según el tablero ('' si no aplica)
|
||||
}
|
||||
|
||||
export interface EventoHistorialContacto {
|
||||
fechaISO: string
|
||||
usuario: string | null // nombre resuelto; null si fue automatización o desconocido
|
||||
detalle: string
|
||||
fuente: string // 'Contactos' | 'Leads' | 'Oportunidades' | 'Apartado' | 'App'
|
||||
}
|
||||
|
||||
export interface DetalleContacto {
|
||||
contacto: Record<string, string> // columnas del board por título ('nombre' incluido)
|
||||
/** Apartado relacionado más reciente (la propiedad comprada o en proceso) */
|
||||
propiedad: {
|
||||
id: string
|
||||
unidadCodigo: string
|
||||
desarrollo: string
|
||||
estado: string
|
||||
precioFinal: number
|
||||
fechaCompra: string
|
||||
fechaLimite: string
|
||||
atendidoPor: string | null
|
||||
} | null
|
||||
relaciones: {
|
||||
leads: RelacionCrm[]
|
||||
oportunidades: RelacionCrm[]
|
||||
apartados: RelacionCrm[]
|
||||
}
|
||||
historial: EventoHistorialContacto[]
|
||||
}
|
||||
|
||||
/** Últimos 10 dígitos del teléfono (dedup/relación tolerante a lada y formato) */
|
||||
export function claveTelefono(telefono: string): string {
|
||||
const digitos = telefono.replace(/\D/g, '')
|
||||
return digitos.length >= 10 ? digitos.slice(-10) : ''
|
||||
}
|
||||
|
||||
type MondayDetalle = Pick<
|
||||
MondayService,
|
||||
| 'getContactoDetalle'
|
||||
| 'getLeads'
|
||||
| 'getOportunidades'
|
||||
| 'getApartados'
|
||||
| 'getHistorialItems'
|
||||
| 'getUnidad'
|
||||
>
|
||||
|
||||
export interface CapturaLocal {
|
||||
leadId: string
|
||||
nombreLead: string
|
||||
createdAt: Date
|
||||
usuario: { nombre: string } | null
|
||||
}
|
||||
|
||||
export interface ClienteLocal {
|
||||
id: string
|
||||
nombreCompleto: string
|
||||
proyecto: string
|
||||
unidadCodigo: string | null
|
||||
createdAt: Date
|
||||
capturadoPor: { nombre: string } | null
|
||||
_count?: { documentos: number }
|
||||
}
|
||||
|
||||
export async function detalleContacto(
|
||||
monday: MondayDetalle,
|
||||
contactoId: string,
|
||||
nombresPorMondayId: Map<number, string>,
|
||||
capturas: CapturaLocal[],
|
||||
clienteLocal: ClienteLocal | null,
|
||||
): Promise<DetalleContacto | null> {
|
||||
const contacto = await monday.getContactoDetalle(contactoId)
|
||||
if (contacto === null) return null
|
||||
|
||||
const nombre = (id: number | null): string | null =>
|
||||
id !== null ? (nombresPorMondayId.get(id) ?? null) : null
|
||||
|
||||
const tel = claveTelefono(contacto['Teléfono'] ?? '')
|
||||
const email = (contacto['Email'] ?? '').trim().toLowerCase()
|
||||
|
||||
const coincide = (t: string, e: string): boolean =>
|
||||
(tel !== '' && claveTelefono(t) === tel) || (email !== '' && e.trim().toLowerCase() === email)
|
||||
|
||||
const [leads, oportunidades, apartados] = await Promise.all([
|
||||
monday.getLeads(),
|
||||
monday.getOportunidades(),
|
||||
monday.getApartados(),
|
||||
])
|
||||
|
||||
const relLeads = leads.filter((l) => coincide(l.telefono, l.email))
|
||||
const relOportunidades = oportunidades.filter((o) => coincide(o.telefono, o.email))
|
||||
const relApartados = apartados.filter((a) => coincide(a.telefono ?? '', a.email ?? ''))
|
||||
|
||||
// Propiedad: el apartado relacionado "vivo" (no final) o el último del board;
|
||||
// se enriquece con el desarrollo de la unidad (inventario)
|
||||
let propiedad: DetalleContacto['propiedad'] = null
|
||||
const aptoReciente = relApartados.find((a) => !esEstadoFinalApartado(a.estado)) ?? relApartados[relApartados.length - 1]
|
||||
if (aptoReciente) {
|
||||
let desarrollo = ''
|
||||
if (aptoReciente.unidadCodigo !== '') {
|
||||
try {
|
||||
desarrollo = (await monday.getUnidad(aptoReciente.unidadCodigo))?.desarrollo ?? ''
|
||||
} catch {
|
||||
desarrollo = ''
|
||||
}
|
||||
}
|
||||
propiedad = {
|
||||
id: aptoReciente.id,
|
||||
unidadCodigo: aptoReciente.unidadCodigo,
|
||||
desarrollo,
|
||||
estado: aptoReciente.estado,
|
||||
precioFinal: aptoReciente.precioFinal ?? 0,
|
||||
fechaCompra: aptoReciente.fechaCompra ?? '',
|
||||
fechaLimite: aptoReciente.fechaLimite,
|
||||
atendidoPor: nombre(aptoReciente.vendedorIds[0] ?? null),
|
||||
}
|
||||
}
|
||||
|
||||
// Historial: activity log de los items relacionados + del propio contacto,
|
||||
// más los registros locales de captura/alta (Monday no guarda quién capturó)
|
||||
const [evContactos, evLeads, evOportunidades, evApartado] = await Promise.all([
|
||||
monday.getHistorialItems(config.monday.boards.contactos, [contactoId]),
|
||||
monday.getHistorialItems(config.monday.boards.leads, relLeads.map((l) => l.id)),
|
||||
monday.getHistorialItems(config.monday.boards.oportunidades, relOportunidades.map((o) => o.id)),
|
||||
monday.getHistorialItems(config.monday.boards.apartado, relApartados.map((a) => a.id)),
|
||||
])
|
||||
|
||||
const historial: EventoHistorialContacto[] = [
|
||||
...evContactos.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Contactos' })),
|
||||
...evLeads.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Leads' })),
|
||||
...evOportunidades.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Oportunidades' })),
|
||||
...evApartado.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Apartado' })),
|
||||
...capturas.map((c) => ({
|
||||
fechaISO: c.createdAt.toISOString(),
|
||||
usuario: c.usuario?.nombre ?? null,
|
||||
detalle: `Captura en la app (${c.nombreLead})`,
|
||||
fuente: 'App',
|
||||
})),
|
||||
...(clienteLocal
|
||||
? [{
|
||||
fechaISO: clienteLocal.createdAt.toISOString(),
|
||||
usuario: clienteLocal.capturadoPor?.nombre ?? null,
|
||||
detalle: `Alta de cliente registrada${clienteLocal.unidadCodigo ? ` (${clienteLocal.unidadCodigo})` : ''}`,
|
||||
fuente: 'App',
|
||||
}]
|
||||
: []),
|
||||
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
|
||||
|
||||
return {
|
||||
contacto,
|
||||
propiedad,
|
||||
relaciones: {
|
||||
leads: relLeads.map((l) => ({
|
||||
id: l.id,
|
||||
nombre: l.nombre,
|
||||
estado: l.estado,
|
||||
atendidoPor: nombre(l.vendedorIds[0] ?? null),
|
||||
detalle: l.origen,
|
||||
})),
|
||||
oportunidades: relOportunidades.map((o) => ({
|
||||
id: o.id,
|
||||
nombre: o.nombre,
|
||||
estado: o.etapa,
|
||||
atendidoPor: nombre(o.asesorIds[0] ?? null),
|
||||
detalle: [o.desarrollo, o.modeloInteres].filter((p) => p).join(' · '),
|
||||
})),
|
||||
apartados: relApartados.map((a) => ({
|
||||
id: a.id,
|
||||
nombre: a.nombre,
|
||||
estado: a.estado,
|
||||
atendidoPor: nombre(a.vendedorIds[0] ?? null),
|
||||
detalle: a.unidadCodigo,
|
||||
})),
|
||||
},
|
||||
historial,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user