Files
reformix-hackaton/mvp/Whatsapp-bot/src/leads/leads.service.ts
2026-06-02 23:11:46 -04:00

214 lines
6.4 KiB
TypeScript

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<string, string[]> = {
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<string, keyof Lead> = {
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<Lead>,
) {}
/**
* 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<Lead> {
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<Lead | null> {
return this.leadRepo.findOne({ where: { telefono } });
}
async findById(id: number): Promise<Lead | null> {
return this.leadRepo.findOne({ where: { id } });
}
async findByEstado(estado: EstadoLead): Promise<Lead[]> {
return this.leadRepo.find({ where: { estado_actual: estado } });
}
async updateEstado(lead: Lead, estado: EstadoLead | string): Promise<Lead> {
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<Lead>): Promise<Lead> {
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<Lead> {
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<Lead>,
options?: { nuevoEstado?: string; viable?: boolean },
): Promise<Lead> {
const patch: Partial<Lead> = { ...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<void> {
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<Lead> {
return this.leadRepo.save(lead);
}
}