214 lines
6.4 KiB
TypeScript
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);
|
|
}
|
|
}
|