fix(auditoria): afluencia (validacion, update sin perder datos ni retroceder etapa, dedup cruzado, notas), alta cliente (registro tras firma, documentos por cliente, aviso de fallos) y reporte de clientes aplanado

This commit is contained in:
2026-09-18 16:05:18 +00:00
parent 4965a4488a
commit 656116c4e5
13 changed files with 295 additions and 59 deletions

View File

@@ -115,11 +115,13 @@ export class AutomatizacionesService {
if (configService.obtenerConfigSync('round_robin_habilitado') !== 'true') {
return { asignados: 0, detalle: [] }
}
// Leads locales sin asignar
// Leads locales sin asignar (los que ya tienen capturista NO se reasignan:
// el round-robin sólo reparte los que entraron sin dueño)
const leadsSinAsignar = await prisma.leadCaptura.findMany({
where: {
board: 'LEADS',
etapa: 'NUEVO',
usuarioId: 'desconocido',
},
})
const leadIdsAsignados = (

View File

@@ -76,13 +76,12 @@ export class CrmLocalService {
* teléfono y email entre los leads locales (mismo contrato 409 de siempre).
*/
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
// Dedup por teléfono (normalizando: últimos 10 dígitos)
// Dedup por teléfono (normalizado a los últimos 10 dígitos) en CUALQUIER
// tablero: evita que la misma persona quede como lead y como afluencia.
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
const telLimpio = datos.telefono.replace(/\s+/g, '').slice(-10)
const telLimpio = soloDigitos(datos.telefono).slice(-10)
if (telLimpio.length >= 7) {
const existente = await prisma.leadCaptura.findFirst({
where: { board: 'LEADS', telefono: { contains: telLimpio } },
})
const existente = await buscarPorTelefono(telLimpio)
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } }
}
}
@@ -124,30 +123,65 @@ export class CrmLocalService {
if (oportunidadId !== undefined) {
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
if (existente) {
await prisma.leadCaptura.update({
where: { leadId: oportunidadId },
// Sin fila local (id heredado del CRM anterior): se crea la sombra con
// ese id para que la captura no se pierda en silencio.
if (!existente) {
await prisma.leadCaptura.create({
data: {
leadId: oportunidadId,
oportunidadId,
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null,
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
datosJson: JSON.stringify(datos),
pendienteSync: true,
},
})
return { id: oportunidadId, oportunidadId, actualizada: true }
}
// Sólo se pisan los campos capturados: lo que no viene en el payload se
// conserva (antes se borraba con null y se perdía la cita agendada).
const cambio: Record<string, unknown> = { pendienteSync: true }
if (datos.nombre.trim() !== '') cambio['nombreLead'] = datos.nombre
if (datos.telefono !== undefined) cambio['telefono'] = datos.telefono
if (datos.email !== undefined) cambio['email'] = datos.email
if (datos.origen !== undefined) cambio['origen'] = datos.origen
if (datos.desarrollo !== undefined) cambio['desarrollo'] = datos.desarrollo
if (modelo !== undefined) cambio['modelo'] = modelo
// La etapa NO retrocede: sólo se marca Afluencia si aún no había avanzado
const etapaActual = existente.etapa.trim()
const etapasIniciales = ['', 'NUEVO', 'Afluencia']
const etapaFinal = etapasIniciales.includes(etapaActual) ? 'Afluencia' : existente.etapa
if (etapaFinal !== existente.etapa) cambio['etapa'] = etapaFinal
// datosJson se MEZCLA: preserva fechaCita y los campos no reenviados
let previos: Record<string, unknown> = {}
try {
previos = existente.datosJson ? (JSON.parse(existente.datosJson) as Record<string, unknown>) : {}
} catch {
previos = {}
}
const nuevos = Object.fromEntries(Object.entries(datos).filter(([, v]) => v !== undefined && v !== null))
cambio['datosJson'] = JSON.stringify({ ...previos, ...nuevos })
await prisma.leadCaptura.update({ where: { leadId: oportunidadId }, data: cambio })
return { id: oportunidadId, oportunidadId, actualizada: true }
}
if (datos.telefono !== undefined) {
const duplicado = await prisma.leadCaptura.findFirst({
where: { telefono: datos.telefono, board: 'OPORTUNIDADES' },
})
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
const telLimpio = soloDigitos(datos.telefono).slice(-10)
if (telLimpio.length >= 7) {
const duplicado = await buscarPorTelefono(telLimpio)
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
}
}
const id = idLocal()
@@ -411,4 +445,20 @@ export class CrmLocalService {
}
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
export const crmLocal = new CrmLocalService()
export const crmLocal = new CrmLocalService()/** Sólo dígitos de un teléfono (quita +52, espacios, guiones, paréntesis) */
function soloDigitos(telefono: string): string {
return telefono.replace(/\D/g, '')
}
/**
* Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero.
* Así el mismo número no se duplica entre Leads, Oportunidades y Apartados.
*/
async function buscarPorTelefono(diezDigitos: string) {
return prisma.leadCaptura.findFirst({
where: { telefono: { contains: diezDigitos } },
orderBy: { createdAt: 'desc' },
})
}