import { randomUUID } from 'node:crypto' import { AppError, NotFoundError } from '../utils/errors.js' import { afluenciaCompleta } from '../config/vocabulario.js' import { claveApartadoCreado } from './automatizaciones.service.js' import { calcularClienteClave } from './recordatorio-vendedor.service.js' import { prisma } from './prisma.js' /** * CRM local: implementa el embudo completo contra SQLite (tabla LeadCaptura). * * - Entidad local del embudo: LeadCaptura (board LEADS | OPORTUNIDADES | * APARTADO + etapa). Ids locales: `loc_` (ver `esIdLocal`). * - Las operaciones que tocan varias tablas van en $transaction. * - SECUENCIAL a propósito (SQLite P1008): nada de Promise.all con escrituras. */ /** Board local del embudo (columna LeadCaptura.board) */ export type BoardLocal = 'LEADS' | 'OPORTUNIDADES' | 'APARTADO' /** true si el id es local (`loc_`) en lugar de un id heredado del CRM anterior */ export function esIdLocal(id: string): boolean { return id.startsWith('loc_') } function idLocal(): string { return `loc_${randomUUID()}` } /** Captura rápida de lead (forma corta de /hoy) */ export interface DatosLeadLocal { nombre: string telefono?: string | undefined email?: string | undefined origen?: string | undefined desarrollo?: string | undefined notas?: string | undefined } /** Alta Afluencia: captura ligera + perfilamiento completo */ export interface DatosAfluenciaLocal extends DatosLeadLocal { /** Con valor: se ACTUALIZA esa oportunidad en vez de crear una nueva */ oportunidadId?: string | undefined modelo?: string | undefined medioContacto?: string | undefined edad?: number | undefined estadoCivil?: string | undefined tipoEmpleo?: string | undefined ocupacion?: string | undefined rangoSalarial?: string | undefined credito?: string | undefined tipoEconomia?: string | undefined califica?: string | undefined loQuierePara?: string | undefined } /** Misma forma que ResultadoCrearLead del controller (sin importarlo: ciclo) */ export type ResultadoCapturaLocal = | { id: string; oportunidadId: string | null; actualizada?: boolean } | { duplicado: { id: string; nombre: string } } /** Resultado del buscador local (misma forma que ResultadoBusquedaLead) */ export interface ResultadoBusquedaLocal { id: string nombre: string telefono: string email: string estado: string origen: string fuente: 'lead' | 'oportunidad' desarrollo: string modeloInteres: string } export class CrmLocalService { /** * Captura rápida local: LeadCaptura board=LEADS etapa 'NUEVO'. Dedup por * teléfono y email entre los leads locales (mismo contrato 409 de siempre). */ async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise { const telNorm = datos.telefono !== undefined && datos.telefono.trim() !== '' ? normalizaTelefono(datos.telefono) : null const emailNorm = datos.email !== undefined && datos.email.trim() !== '' ? datos.email.trim().toLowerCase() : null // Serializa la captura por teléfono/email: dos envíos simultáneos no deben // pasar juntos la validación de duplicado. return conLock(telNorm ? `tel:${telNorm}` : emailNorm ? `mail:${emailNorm}` : `cap:${usuarioId ?? 'anon'}`, async () => { // Dedup por teléfono normalizado en CUALQUIER tablero: evita que la // misma persona quede como lead y como afluencia. if (telNorm) { const existente = await buscarPorTelefono(telNorm) if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } } } // Dedup por email (minúsculas): mismo criterio que el teléfono (cualquier // tablero, sin etapas cerradas) if (emailNorm) { // Busca el email normalizado y, si venía distinto (mayúsculas), también // tal cual: los registros viejos conservan el formato original. const variantes = [{ email: emailNorm }] if (datos.email && datos.email.trim() !== emailNorm) variantes.push({ email: datos.email.trim() }) const existenteEmail = await prisma.leadCaptura.findFirst({ where: { OR: variantes, etapa: { notIn: ETAPAS_CERRADAS } }, }) if (existenteEmail) return { duplicado: { id: existenteEmail.leadId, nombre: existenteEmail.nombreLead } } } const id = idLocal() const fila = await prisma.leadCaptura.create({ data: { leadId: id, oportunidadId: null, // los leads no generan oportunidad usuarioId: usuarioId ?? 'desconocido', nombreLead: datos.nombre, telefono: datos.telefono ?? null, telefonoNorm: telNorm, email: emailNorm, origen: datos.origen ?? null, desarrollo: datos.desarrollo ?? null, board: 'LEADS', etapa: 'NUEVO', datosJson: JSON.stringify({ notas: datos.notas ?? null }), pendienteSync: true, }, }) return { id: fila.leadId, oportunidadId: null } }) } /** * Alta Afluencia local. Sin oportunidadId: LeadCaptura board=OPORTUNIDADES * etapa 'Afluencia' con el perfilamiento en datosJson (además de los campos * planos) + auto-mapping LeadOportunidad. Con oportunidadId (botón * "Afluencia" de Mis oportunidades): actualiza la fila local si existe. */ async crearAfluencia(datos: DatosAfluenciaLocal, usuarioId?: string): Promise { const { oportunidadId, modelo } = datos if (oportunidadId !== undefined) { const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } }) // Sin fila local: id inválido o liga guardada de una limpieza anterior. // Antes se creaba una oportunidad "sombra" fantasma con ese id; ahora se // rechaza para no sembrar registros huérfanos en Oportunidades. if (!existente) { throw new NotFoundError(`No existe la oportunidad ${oportunidadId}`) } // 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 = { pendienteSync: true } if (datos.nombre.trim() !== '') cambio['nombreLead'] = datos.nombre if (datos.telefono !== undefined) { cambio['telefono'] = datos.telefono cambio['telefonoNorm'] = datos.telefono.trim() === '' ? null : normalizaTelefono(datos.telefono) } if (datos.email !== undefined) cambio['email'] = datos.email.trim() === '' ? null : datos.email.trim().toLowerCase() 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 = {} try { previos = existente.datosJson ? (JSON.parse(existente.datosJson) as Record) : {} } 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 } } const telNorm = datos.telefono !== undefined && datos.telefono.trim() !== '' ? normalizaTelefono(datos.telefono) : null const emailNorm = datos.email !== undefined && datos.email.trim() !== '' ? datos.email.trim().toLowerCase() : null // Dedup + creación serializados por teléfono/email (mismo criterio que // crearLead: dos envíos simultáneos no deben duplicar). return conLock(telNorm ? `tel:${telNorm}` : emailNorm ? `mail:${emailNorm}` : `afl:${usuarioId ?? 'anon'}`, async () => { if (telNorm) { const duplicado = await buscarPorTelefono(telNorm) if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } } } const id = idLocal() await prisma.$transaction([ prisma.leadCaptura.create({ data: { leadId: id, oportunidadId: id, // la afluencia ES la oportunidad usuarioId: usuarioId ?? 'desconocido', nombreLead: datos.nombre, telefono: datos.telefono ?? null, telefonoNorm: telNorm, email: emailNorm, origen: datos.origen ?? null, desarrollo: datos.desarrollo ?? null, modelo: modelo ?? null, board: 'OPORTUNIDADES', etapa: 'Afluencia', datosJson: JSON.stringify(datos), pendienteSync: true, }, }), // Auto-mapping leadId = oportunidadId: el alta de clientes encuentra así // la oportunidad a mover a 'Aparto' prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }), ]) return { id, oportunidadId: id } }) } /** * Fila local del item, creándola "sombra" si no existe y `sombra` es true. * Las sombras cubren ids heredados del CRM anterior sin fila local: la * acción queda reflejada en Mi día. Sin `sombra` → 404. */ private async filaLocal(itemId: string, board: BoardLocal, usuarioId: string | undefined, sombra: boolean) { const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) if (row) return row if (!sombra) throw new NotFoundError(`No existe registro local para el item ${itemId}`) return prisma.leadCaptura.create({ data: { leadId: itemId, usuarioId: usuarioId ?? 'desconocido', nombreLead: 'Registro previo (CRM anterior)', board, etapa: 'NUEVO', mondayItemId: esIdLocal(itemId) ? null : itemId, pendienteSync: true, }, }) } /** * Cambio de estado/etapa local (boards LEADS, OPORTUNIDADES y APARTADO): * actualiza `etapa` (+updatedAt vía Prisma) y guarda la fecha de la cita en * datosJson si aplica. */ async cambiarEstado( itemId: string, label: string, board: BoardLocal, opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {}, ): Promise { await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false) // Guardar fechaCita en datosJson (oportunidad y apartado) para que la // muestre Mi día y el calendario const updateData: Record = { etapa: label, pendienteSync: true } if (opciones.fecha !== undefined && (board === 'OPORTUNIDADES' || board === 'APARTADO')) { const existente = await prisma.leadCaptura.findUnique({ where: { leadId: itemId }, select: { datosJson: true } }) let datos: Record = {} if (existente?.datosJson) { try { datos = JSON.parse(existente.datosJson) as Record } catch { /* ignorar */ } } datos['fechaCita'] = opciones.fecha updateData['datosJson'] = JSON.stringify(datos) } await prisma.leadCaptura.update({ where: { leadId: itemId }, data: updateData, }) } /** * Descarte local: etapa terminal ('Descartado' en Leads, 'Perdido' en * Oportunidades); guarda motivo/submotivo. */ async descartar( itemId: string, motivo: string, opciones: { fuente?: 'lead' | 'oportunidad' | undefined subcategoria?: string | undefined comentario?: string | undefined usuarioId?: string | undefined sombra?: boolean | undefined } = {}, ): Promise { const fuente = opciones.fuente ?? 'oportunidad' const board: BoardLocal = fuente === 'lead' ? 'LEADS' : 'OPORTUNIDADES' const row = await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false) const etapaTerminal = fuente === 'lead' ? 'Descartado' : 'Perdido' await prisma.leadCaptura.update({ where: { leadId: itemId }, data: { etapa: etapaTerminal, motivo, submotivo: opciones.subcategoria ?? null, pendienteSync: true, }, }) } /** * Conversión local lead → oportunidad (botón "Agendo" de Mis leads): el lead * queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando * los datos y se liga LeadOportunidad + `oportunidadId` en la captura. Las * notas (recordatorios) del lead pasan a apuntar a la oportunidad para que * sobrevivan al cambio de etapa. Si el lead ya fue convertido → 409. Devuelve * la fila local de la oportunidad. */ async convertirLead(leadIdLocal: string, _usuarioId?: string) { const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } }) if (!lead) throw new NotFoundError('Lead no encontrado') if (lead.oportunidadId !== null) throw new AppError(409, 'El lead ya fue convertido en oportunidad') const oportunidadId = idLocal() const resultados = await prisma.$transaction([ prisma.leadCaptura.update({ where: { leadId: leadIdLocal }, data: { etapa: 'Calificado', oportunidadId, pendienteSync: true }, }), prisma.leadCaptura.create({ data: { leadId: oportunidadId, oportunidadId, usuarioId: lead.usuarioId, nombreLead: lead.nombreLead, telefono: lead.telefono, telefonoNorm: lead.telefonoNorm, email: lead.email, origen: lead.origen, desarrollo: lead.desarrollo, modelo: lead.modelo, board: 'OPORTUNIDADES', etapa: 'Agendo Cita', datosJson: lead.datosJson, pendienteSync: true, }, }), prisma.leadOportunidad.create({ data: { leadId: leadIdLocal, oportunidadId } }), // Las notas del lead viejo siguen a la nueva oportunidad (bitácora persiste) prisma.recordatorioVendedor.updateMany({ where: { leadId: leadIdLocal }, data: { leadId: oportunidadId, clienteClave: calcularClienteClave(lead.nombreLead, lead.telefono) }, }), ]) return resultados[1] } /** * "Aparto" local: la oportunidad pasa a etapa 'Aparto' y se crea el apartado * local (board=APARTADO, etapa 'Pago Apartado', primer paso del pipeline * Enkontrol) con los datos conocidos. Las notas (recordatorios) de la * oportunidad pasan a apuntar al apartado para que sobrevivan al cambio de * board. Idempotente con el mismo dedup `apartado-creado:` de las * automatizaciones. * * `opciones.unidadCodigo` deja la unidad apartada en el `datosJson` del * apartado (la cotización del vendedor o la unidad de interés): es lo que * después leen el bloqueo de inventario y Mi día. */ async apartar( oportunidadIdLocal: string, usuarioId?: string, opciones: { unidadCodigo?: string | undefined } = {}, ) { // Sombra para ids heredados sin fila local const oportunidad = await this.filaLocal( oportunidadIdLocal, 'OPORTUNIDADES', usuarioId, !esIdLocal(oportunidadIdLocal), ) // Regla de negocio: no se aparta sin la afluencia completa del cliente if (!afluenciaCompleta(oportunidad.datosJson)) { throw new AppError(400, 'Primero debes capturar la afluencia del cliente (botón Afluencia) antes de apartar') } const clave = claveApartadoCreado(oportunidadIdLocal) const previo = await prisma.automatizacionState.findUnique({ where: { clave } }) if (previo !== null) { const existente = await prisma.leadCaptura.findUnique({ where: { leadId: previo.valor } }) if (existente) return existente } const apartadoId = idLocal() const datosJson = conUnidadCodigo(oportunidad.datosJson, opciones.unidadCodigo) const resultados = await prisma.$transaction([ prisma.leadCaptura.update({ where: { leadId: oportunidadIdLocal }, data: { etapa: 'Aparto', pendienteSync: true }, }), prisma.leadCaptura.create({ data: { leadId: apartadoId, usuarioId: oportunidad.usuarioId, nombreLead: oportunidad.nombreLead, telefono: oportunidad.telefono, telefonoNorm: oportunidad.telefonoNorm, email: oportunidad.email, origen: oportunidad.origen, desarrollo: oportunidad.desarrollo, modelo: oportunidad.modelo, board: 'APARTADO', etapa: 'Pago Apartado', datosJson, // Fecha límite del apartado en la COLUMNA: la leen el KPI "Apartados // por vencer", el cron de avisos y la lista de Mi día (antes sólo se // calculaba en el JSON del alta y la columna quedaba vacía). fechaLimite: fechaLimiteApartado(datosJson), pendienteSync: true, }, }), prisma.automatizacionState.upsert({ where: { clave }, update: { valor: apartadoId }, create: { clave, valor: apartadoId }, }), // Las notas de la oportunidad siguen al apartado (bitácora persiste) prisma.recordatorioVendedor.updateMany({ where: { leadId: oportunidadIdLocal }, data: { leadId: apartadoId, clienteClave: calcularClienteClave(oportunidad.nombreLead, oportunidad.telefono) }, }), ]) return resultados[1] } /** * Búsqueda local para el picker del alta de clientes (y el buscador del * cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular. * Con `alcance.soloPropio` (rol vendedor) filtra a los registros propios: * cada asesor sólo encuentra sus leads y contactos, no los de sus compañeros. */ async buscarLocales( termino: string, alcance?: { usuarioId: string; soloPropio: boolean }, ): Promise { const propioLead = alcance?.soloPropio ? { usuarioId: alcance.usuarioId } : {} const propioCliente = alcance?.soloPropio ? { capturadoPorId: alcance.usuarioId } : {} // Búsqueda telefónica: el teléfono crudo puede traer espacios, ladas o // caracteres invisibles (p. ej. '+52 +52 664 782 5023'), así que el match // va contra los dígitos normalizados (telefonoNorm), en ambos sentidos: // quien busca '6647825023' o '+52 664 782 5023' debe encontrar el registro. const digitos = termino.replace(/\D/g, '') const pareceTelefono = /^[\d\s+().-]+$/.test(termino) && digitos.length >= 2 // Con lada (+52 ...) el término supera los 10 dígitos del número nacional: // se agrega también el sufijo de 10 dígitos para el match en telefonoNorm const sufijoNacional = digitos.length > 10 ? digitos.slice(-10) : null const condTelefonoLead = digitos.length >= 2 ? [ { telefono: { contains: termino } }, { telefonoNorm: { contains: digitos } }, ...(sufijoNacional !== null ? [{ telefonoNorm: { contains: sufijoNacional } }] : []), ] : [{ telefono: { contains: termino } }] const condTelefonoCliente = digitos.length >= 2 ? [{ celular: { contains: digitos } }] : [] // Nombres: variantes con/sin acento (y ñ/n) porque el asesor no siempre // teclea las tildes ('jazmin' debe encontrar a 'Jazmín'). Se genera la // forma sin acentos más una variante por vocal acentuada (una a la vez), // suficiente en la práctica y acotado en tamaño. const A_QUITA: Record = { á: 'a', à: 'a', ä: 'a', â: 'a', é: 'e', è: 'e', ë: 'e', ê: 'e', í: 'i', ï: 'i', î: 'i', ó: 'o', ö: 'o', ô: 'o', ú: 'u', ü: 'u', û: 'u', Á: 'A', À: 'A', Ä: 'A', Â: 'A', É: 'E', È: 'E', Ë: 'E', Ê: 'E', Í: 'I', Ï: 'I', Î: 'I', Ó: 'O', Ö: 'O', Ô: 'O', Ú: 'U', Ü: 'U', Û: 'U', } const A_PONE: Record = { a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú', A: 'Á', E: 'É', I: 'Í', O: 'Ó', U: 'Ú', n: 'ñ', N: 'Ñ', } const variantesNombre = (t: string): string[] => { const base = [...t].map((c) => A_QUITA[c] ?? c).join('') const variants = new Set([base]) const chars = [...base] for (let i = 0; i < chars.length; i++) { const ch = chars[i] as string const acentuada = A_PONE[ch] if (acentuada !== undefined) variants.add(chars.slice(0, i).join('') + acentuada + chars.slice(i + 1).join('')) } if (base !== t) variants.add(t) return [...variants] } const condNombreLead = variantesNombre(termino).map((v) => ({ nombreLead: { contains: v } })) const condNombreCliente = variantesNombre(termino).map((v) => ({ nombreCompleto: { contains: v } })) const capturas = await prisma.leadCaptura.findMany({ where: { AND: [propioLead, { OR: [...condNombreLead, ...condTelefonoLead] }] }, orderBy: { updatedAt: 'desc' }, take: 50, }) const clientes = await prisma.cliente.findMany({ where: { AND: [propioCliente, { OR: [...condNombreCliente, { celular: { contains: termino } }, ...condTelefonoCliente] }] }, orderBy: { createdAt: 'desc' }, take: 50, }) /** Teléfono buscado: match si los dígitos del registro contienen los del * término, o viceversa (búsqueda con lada completa vs registro sin lada). */ const coincideTelefono = (crudo: string | null | undefined): boolean => { if (digitos.length < 2) return true const d = (crudo ?? '').replace(/\D/g, '') return d.includes(digitos) || (d.length >= 6 && digitos.includes(d)) } const candidatos = [ ...capturas .filter((c) => !pareceTelefono || coincideTelefono(c.telefono) || c.nombreLead.includes(termino)) .map((c) => ({ id: c.leadId, nombre: c.nombreLead, telefono: c.telefono ?? '', email: c.email ?? '', estado: c.etapa, origen: c.origen ?? '', fuente: (c.board === 'LEADS' ? 'lead' : 'oportunidad') as 'lead' | 'oportunidad', desarrollo: c.desarrollo ?? '', modeloInteres: c.modelo ?? '', orden: c.updatedAt, })), ...clientes .filter((c) => !pareceTelefono || coincideTelefono(c.celular) || c.nombreCompleto.includes(termino)) .map((c) => ({ id: c.id, nombre: c.nombreCompleto, telefono: c.celular ?? '', email: c.email ?? '', estado: '', origen: '', fuente: 'oportunidad' as const, desarrollo: c.proyecto, modeloInteres: '', orden: c.createdAt, })), ] return candidatos .sort((a, b) => b.orden.getTime() - a.orden.getTime()) .slice(0, 15) .map(({ orden: _orden, ...resto }) => resto) } /** * Reasignación en cadena (solo admin): mueve el registro y TODA su cadena * al nuevo vendedor. Un cliente puede tener hasta 3 filas (lead → * oportunidad → apartado) y cada una tiene su propio leadId; reasignar sólo * la fila tocada dejaba al cliente "partido" entre dos vendedores (el lead * con uno y la oportunidad/apartado con el otro). También pasan las * actividades PENDIENTES (calendario); las completadas quedan como * historial del vendedor original. Devuelve cuántas filas se movieron. */ async reasignarCadena(itemId: string, usuarioId: string): Promise { const origen = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) if (!origen) throw new NotFoundError('Lead no encontrado') const ids = new Set([itemId]) let oportunidadId: string | null = null if (origen.board === 'LEADS') { oportunidadId = origen.oportunidadId } else if (origen.board === 'OPORTUNIDADES') { oportunidadId = itemId } else if (origen.board === 'APARTADO') { // Cadena inversa: el estado 'apartado-creado:{O}' apunta a este apartado const estado = await prisma.automatizacionState.findFirst({ where: { clave: { startsWith: 'apartado-creado:' }, valor: itemId }, }) oportunidadId = estado?.clave.slice('apartado-creado:'.length) ?? null } if (oportunidadId !== null) { ids.add(oportunidadId) // El lead origen (tabla LEADS) apunta a la oportunidad con su columna const lead = await prisma.leadCaptura.findFirst({ where: { board: 'LEADS', oportunidadId }, select: { leadId: true }, }) if (lead) ids.add(lead.leadId) // El apartado cuelga de la oportunidad por la clave de automatización const apartado = await prisma.automatizacionState.findUnique({ where: { clave: claveApartadoCreado(oportunidadId) }, }) if (apartado) ids.add(apartado.valor) } const listaIds = [...ids] const resultados = await prisma.$transaction([ prisma.leadCaptura.updateMany({ where: { leadId: { in: listaIds } }, data: { usuarioId }, }), prisma.recordatorioVendedor.updateMany({ where: { leadId: { in: listaIds }, completado: false }, data: { usuarioId }, }), ]) return resultados[0].count } } /** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */ export const crmLocal = new CrmLocalService()/** Fecha límite de un apartado: la del JSON si existe, si no hoy + 15 días */ function fechaLimiteApartado(datosJson: string | null): Date { try { const datos = datosJson ? (JSON.parse(datosJson) as Record) : {} const fecha = datos['fechaLimite'] if (typeof fecha === 'string' && fecha.trim() !== '') { const d = new Date(fecha.length === 10 ? `${fecha}T00:00:00.000Z` : fecha) if (!Number.isNaN(d.getTime())) return d } } catch { /* JSON corrupto: se calcula por defecto */ } const limite = new Date() limite.setDate(limite.getDate() + 15) return limite } /** Sólo dígitos de un teléfono (quita +52, espacios, guiones, paréntesis) */ function soloDigitos(telefono: string): string { return telefono.replace(/\D/g, '') } /** * Teléfono normalizado para dedup: sólo dígitos; con 10 o más toma los * últimos 10 (cubre lada duplicada tipo "+52 52 ..."); con 7-9 conserva * todos. Menos de 7 dígitos no es buscable (null). Coincide con el backfill * de la migración 20260926120000_lead_telefono_norm. */ export function normalizaTelefono(telefono: string | null | undefined): string | null { if (!telefono) return null const digitos = soloDigitos(telefono) if (digitos.length >= 10) return digitos.slice(-10) if (digitos.length >= 7) return digitos return null } /** * Mutex en memoria por clave (un sólo proceso Node): serializa la pareja * validar-duplicado + crear para el mismo teléfono/email. Sin esto, dos * envíos simultáneos (doble clic en dos pestañas, red lenta) pasaban ambos * la validación y duplicaban el registro. */ const colasLock = new Map>() function conLock(clave: string, fn: () => Promise): Promise { const encolada = (colasLock.get(clave) ?? Promise.resolve()).then(fn, fn) colasLock.set(clave, encolada) const libera = () => { if (colasLock.get(clave) === encolada) colasLock.delete(clave) } void encolada.then(libera, libera) return encolada } /** * `datosJson` con la unidad apartada dentro (mezcla, no reemplaza: conserva * fechaCita, fechaLimite y el perfilamiento de la captura). Sin unidadCodigo * devuelve el JSON original tal cual. */ function conUnidadCodigo(datosJson: string | null, unidadCodigo?: string): string | null { const unidad = unidadCodigo?.trim() if (!unidad) return datosJson let datos: Record = {} if (datosJson) { try { datos = JSON.parse(datosJson) as Record } catch { datos = {} } } return JSON.stringify({ ...datos, unidadCodigo: unidad }) } /** * Etapas que cierran el ciclo de un registro: descartados, perdidos y * cancelados. Ya NO bloquean una captura nueva con el mismo teléfono/email: * tras una limpieza (o un descarte) el vendedor debe poder volver a capturar * a esa persona. Verificado en vivo: restos de APARTADO cancelados bloqueaban * re-capturas tras la última limpieza de leads. */ const ETAPAS_CERRADAS = ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado'] /** * Busca un contacto por teléfono normalizado (columna telefonoNorm) en * CUALQUIER tablero, excluyendo etapas cerradas. Así el mismo número no se * duplica entre tableros activos, pero los registros limpiados o descartados * no estorban. La búsqueda anterior por `telefono contains` fallaba contra * teléfonos guardados con espacios ("+52 664 225 7841" no contiene * "6642257841") y dejaba pasar duplicados. */ async function buscarPorTelefono(telefonoNormalizado: string) { return prisma.leadCaptura.findFirst({ where: { telefonoNorm: telefonoNormalizado, etapa: { notIn: ETAPAS_CERRADAS } }, orderBy: { createdAt: 'desc' }, }) }