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:
@@ -202,14 +202,8 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
|
||||
})
|
||||
return
|
||||
}
|
||||
// Actualización de oportunidad existente (botón "Afluencia" de Mi día): no
|
||||
// hay captura nueva que registrar
|
||||
if (resultado.actualizada === true) {
|
||||
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
|
||||
return
|
||||
}
|
||||
// La nota del alta de afluencia también vive en la bitácora (con clave de
|
||||
// cliente para que siga al contacto en las fases siguientes).
|
||||
// La nota del alta/actualización de afluencia vive en la bitácora (con clave
|
||||
// de cliente para que siga al contacto en las fases siguientes).
|
||||
if (body.notas !== undefined && body.notas.trim() !== '') {
|
||||
await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', {
|
||||
titulo: body.notas,
|
||||
@@ -218,6 +212,11 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
|
||||
fechaRecordar: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
// Actualización de oportunidad existente (botón "Afluencia" de Mi día)
|
||||
if (resultado.actualizada === true) {
|
||||
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
|
||||
return
|
||||
}
|
||||
res.status(201).json({
|
||||
id: resultado.id,
|
||||
estado: 'Afluencia',
|
||||
|
||||
@@ -51,6 +51,42 @@ const campoDesarrollo = (query: Record<string, unknown>): string | undefined =>
|
||||
const ETAPAS_CANCELACION = ['Descartado', 'Perdido', 'Cancelacion Apartado', 'Cancelo']
|
||||
const FASES_ESCRITURACION: readonly string[] = VOCABULARIO.FASES_ESCRITURACION
|
||||
|
||||
/**
|
||||
* Aplana el payload del alta de cliente, que viene anidado por secciones
|
||||
* ({ datosGenerales, domicilio, empleo, economicos, coacreditado, … }) a un
|
||||
* mapa plano de campos (usuarioEnkontrol, actividadOcupacion, compania, …).
|
||||
*/
|
||||
function perfilDeCliente(payloadJson: string | null): Record<string, string> {
|
||||
if (!payloadJson) return {}
|
||||
try {
|
||||
const datos = JSON.parse(payloadJson) as Record<string, unknown>
|
||||
const plano: Record<string, string> = {}
|
||||
// Secciones que NO se aplanan: el co-acreditado repite los mismos campos
|
||||
// (compania, ingresosMensuales, estadoCivil…) y, al venir después, vacíos,
|
||||
// sobrescribía los datos reales del cliente. Documentos/firma/foto tampoco
|
||||
// aportan datos de perfil.
|
||||
const SECCIONES_IGNORADAS = new Set(['coacreditado', 'documentos', 'firma', 'foto', 'referencias'])
|
||||
const recorrer = (obj: Record<string, unknown>, raiz: boolean): void => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v == null) continue
|
||||
if (typeof v === 'object' && !Array.isArray(v)) {
|
||||
if (raiz && SECCIONES_IGNORADAS.has(k)) continue
|
||||
recorrer(v as Record<string, unknown>, false)
|
||||
continue
|
||||
}
|
||||
if (typeof v === 'object') continue
|
||||
const texto = String(v)
|
||||
if (texto.trim() === '') continue
|
||||
if (plano[k] === undefined) plano[k] = texto // gana el primer valor no vacío
|
||||
}
|
||||
}
|
||||
recorrer(datos, true)
|
||||
return plano
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function perfilDeDatos(datosJson: string | null): Record<string, string> {
|
||||
if (!datosJson) return {}
|
||||
try {
|
||||
@@ -286,7 +322,9 @@ export async function reporteClientes(_req: Request, res: Response): Promise<voi
|
||||
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
|
||||
res.json({
|
||||
filas: clientes.map((c) => {
|
||||
const perfil = perfilDeDatos(c.payloadJson)
|
||||
// El alta guarda el payload ANIDADO por secciones (datosGenerales,
|
||||
// empleo, economicos…) — hay que aplanarlo para leer sus campos.
|
||||
const perfil = perfilDeCliente(c.payloadJson)
|
||||
return {
|
||||
nombre: c.nombreCompleto,
|
||||
curp: c.curp,
|
||||
@@ -295,13 +333,14 @@ export async function reporteClientes(_req: Request, res: Response): Promise<voi
|
||||
celular: c.celular ?? '',
|
||||
proyecto: c.proyecto,
|
||||
unidad: c.unidadCodigo ?? '',
|
||||
ekontrol: perfil['ekontrol'] ?? perfil['usuarioEnkontrol'] ?? '',
|
||||
ekontrol: perfil['usuarioEnkontrol'] ?? perfil['ekontrol'] ?? '',
|
||||
estadoCivil: perfil['estadoCivil'] ?? '',
|
||||
ocupacion: perfil['ocupacion'] ?? '',
|
||||
ocupacion: perfil['actividadOcupacion'] ?? perfil['ocupacion'] ?? '',
|
||||
rangoSalarial: perfil['rangoSalarial'] ?? '',
|
||||
empresa: perfil['compania'] ?? perfil['empresa'] ?? '',
|
||||
antiguedad: perfil['antiguedad'] ?? '',
|
||||
ingresoMensual: perfil['ingresoMensual'] ?? perfil['ingresos'] ?? '',
|
||||
antiguedad: [perfil['antiguedadAnios'], perfil['antiguedadMeses']].filter(Boolean).join(' años / ') ||
|
||||
perfil['antiguedad'] || '',
|
||||
ingresoMensual: perfil['ingresosMensuales'] ?? perfil['ingresoMensual'] ?? '',
|
||||
capturadoPor: c.capturadoPorId ? (nombrePorId.get(c.capturadoPorId) ?? '') : '',
|
||||
fecha: c.createdAt.toISOString(),
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user