import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan } from 'typeorm'; import { Lead, EstadoLead } from './lead.entity'; const SECUENCIA_ESTADOS = [ 'nuevo', 'apertura', 'espacio', 'tamano', 'estilo', 'urgencia', 'presupuesto', ] as const; const VALORES_POR_ESTADO: Record = { espacio: ['cocina', 'bano', 'salon', 'integral', 'otro'], tamano: ['menos10', '10a20', '20a40', 'mas40'], estilo: ['funcional', 'cuidado', 'exclusivo'], urgencia: ['urgente', 'medio_plazo', 'frio'], }; const CAMPO_POR_ESTADO: Record = { espacio: 'espacio', tamano: 'rango_m2', estilo: 'estilo', urgencia: 'urgencia', presupuesto: 'presupuesto_declarado', }; @Injectable() export class LeadsService { private readonly logger = new Logger(LeadsService.name); constructor( @InjectRepository(Lead) private readonly leadRepo: Repository, ) {} /** * Normaliza estados legacy del scheduler/DB al flujo de cualificacion. */ normalizarEstadoFlujo(estado: string): string { if (estado === 'en_proceso' || estado === 'recopilando_datos') { return 'apertura'; } return estado; } getSiguienteEstado(estadoActual: string, viable?: boolean): string { const estado = this.normalizarEstadoFlujo(estadoActual); if (estado === 'presupuesto') { return viable === false ? 'fin_no_viable' : 'fin_viable'; } const idx = SECUENCIA_ESTADOS.indexOf( estado as (typeof SECUENCIA_ESTADOS)[number], ); if (idx === -1 || idx >= SECUENCIA_ESTADOS.length - 1) { return estado; } return SECUENCIA_ESTADOS[idx + 1]; } getValoresPermitidos(estado: string): string[] { const estadoNorm = this.normalizarEstadoFlujo(estado); return VALORES_POR_ESTADO[estadoNorm] ?? []; } getCampoParaEstado(estado: string): keyof Lead | null { const estadoNorm = this.normalizarEstadoFlujo(estado); return CAMPO_POR_ESTADO[estadoNorm] ?? null; } esPresupuestoValido(valor: string): boolean { const normalizado = valor.trim().toLowerCase(); if (!normalizado) return false; return /\d/.test(normalizado); } evaluarViabilidad(presupuesto: string): boolean { const numeros = presupuesto.match(/\d[\d.]*/g); if (!numeros?.length) return true; const valor = parseInt(numeros[0].replace(/\./g, ''), 10); if (Number.isNaN(valor)) return true; return valor >= 5000; } /** * Busca un lead por número de teléfono. * Si no existe, lo crea con estado 'nuevo'. */ async findOrCreate(telefono: string): Promise { let lead = await this.leadRepo.findOne({ where: { telefono } }); if (!lead) { lead = this.leadRepo.create({ telefono, estado_actual: 'nuevo' }); lead = await this.leadRepo.save(lead); this.logger.log(`Lead nuevo creado: telefono=${telefono}, id=${lead.id}`); } return lead; } async findByTelefono(telefono: string): Promise { return this.leadRepo.findOne({ where: { telefono } }); } async findById(id: number): Promise { return this.leadRepo.findOne({ where: { id } }); } async findByEstado(estado: EstadoLead): Promise { return this.leadRepo.find({ where: { estado_actual: estado } }); } async updateEstado(lead: Lead, estado: EstadoLead | string): Promise { await this.leadRepo.update(lead.id, { estado_actual: estado as EstadoLead, }); this.logger.log(`Lead id=${lead.id} estado_actual=${estado}`); return this.leadRepo.findOne({ where: { id: lead.id } }); } /** * Actualiza campos del lead según el estado actual del flujo. * Solo actualiza los campos que se pasan en el partial. */ async updateDatos(leadId: number, datos: Partial): Promise { const campos = Object.keys(datos).filter( (k) => datos[k as keyof Lead] !== undefined, ); if (campos.length === 0) { return this.leadRepo.findOne({ where: { id: leadId } }); } await this.leadRepo.update(leadId, datos); this.logger.log( `Lead id=${leadId} datos guardados: ${JSON.stringify(datos)}`, ); return this.leadRepo.findOne({ where: { id: leadId } }); } async marcarViable(lead: Lead, viable: boolean): Promise { const estado = viable ? 'completado' : 'no_viable'; await this.leadRepo.update(lead.id, { viable, estado_actual: estado }); this.logger.log(`Lead id=${lead.id} viable=${viable}, estado=${estado}`); return this.leadRepo.findOne({ where: { id: lead.id } }); } /** * Persiste datos del lead y cambio de estado en una sola operacion. */ async persistirTurno( leadId: number, datos: Partial, options?: { nuevoEstado?: string; viable?: boolean }, ): Promise { const patch: Partial = { ...datos }; if (options?.nuevoEstado === 'fin_viable') { patch.viable = true; patch.estado_actual = 'completado'; } else if (options?.nuevoEstado === 'fin_no_viable') { patch.viable = false; patch.estado_actual = 'no_viable'; } else if (options?.nuevoEstado) { patch.estado_actual = options.nuevoEstado as EstadoLead; } else if (options?.viable !== undefined && options?.viable !== null) { patch.viable = options.viable; patch.estado_actual = options.viable ? 'completado' : 'no_viable'; } const campos = Object.keys(patch).filter( (k) => patch[k as keyof Lead] !== undefined, ); if (campos.length === 0) { return this.leadRepo.findOne({ where: { id: leadId } }); } await this.leadRepo.update(leadId, patch); this.logger.log( `Lead id=${leadId} persistido: ${JSON.stringify(patch)}`, ); return this.leadRepo.findOne({ where: { id: leadId } }); } /** * Marca como perdido cualquier lead en_proceso sin actividad en más de 48h. */ async marcarLeadsPerdidos(): Promise { const hace48h = new Date(Date.now() - 48 * 60 * 60 * 1000); const leadsSinActividad = await this.leadRepo.find({ where: { estado_actual: 'en_proceso', updated_at: LessThan(hace48h), }, }); for (const lead of leadsSinActividad) { lead.estado_actual = 'perdido'; await this.leadRepo.save(lead); this.logger.warn( `Lead id=${lead.id} marcado como perdido por inactividad > 48h`, ); } } async save(lead: Lead): Promise { return this.leadRepo.save(lead); } }