434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import 'dotenv/config';
|
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
import postgres from 'postgres';
|
|
import * as schema from './schema';
|
|
import { eq, sql } from 'drizzle-orm';
|
|
|
|
const connectionString = process.env.DATABASE_URL;
|
|
if (!connectionString) {
|
|
throw new Error('DATABASE_URL no está definida.');
|
|
}
|
|
|
|
const client = postgres(connectionString, { prepare: false });
|
|
const db = drizzle(client, { schema });
|
|
|
|
const euros = (n: number) => Math.round(n * 100); // a céntimos
|
|
|
|
// Cada lead vive en un momento distinto del funnel para poder analizar
|
|
// cuál es el siguiente paso de cada uno. days = hace cuántos días entró.
|
|
type SeedLead = {
|
|
nombre: string;
|
|
telefono: string;
|
|
email: string;
|
|
provincia: string;
|
|
tipoReforma: (typeof schema.tipoReforma.enumValues)[number];
|
|
pipelineStage: (typeof schema.pipelineStage.enumValues)[number];
|
|
estado: (typeof schema.leadEstado.enumValues)[number];
|
|
presupuestoEstimado: number | null;
|
|
transcripcion: string | null;
|
|
entidades: Record<string, unknown> | null;
|
|
renderUrl: string | null;
|
|
pdfUrl: string | null;
|
|
audioUrl: string | null;
|
|
fotos: string[];
|
|
daysAgo: number;
|
|
precioFinal?: number; // solo para ganados
|
|
};
|
|
|
|
const SEED_LEADS: SeedLead[] = [
|
|
{
|
|
// 1. Acaba de dejar sus datos. Siguiente paso: subir fotos.
|
|
nombre: 'Lucía Fernández',
|
|
telefono: '+34 612 003 451',
|
|
email: 'lucia.fernandez@example.com',
|
|
provincia: 'Madrid',
|
|
tipoReforma: 'cocina',
|
|
pipelineStage: 'form_completado',
|
|
estado: 'nuevo',
|
|
presupuestoEstimado: null,
|
|
transcripcion: null,
|
|
entidades: null,
|
|
renderUrl: null,
|
|
pdfUrl: null,
|
|
audioUrl: null,
|
|
fotos: [],
|
|
daysAgo: 0,
|
|
},
|
|
{
|
|
// 2. Subió fotos. Siguiente paso: pre-llamada SMS+WhatsApp.
|
|
nombre: 'Javier Ortega',
|
|
telefono: '+34 633 118 902',
|
|
email: 'javier.ortega@example.com',
|
|
provincia: 'Valencia',
|
|
tipoReforma: 'bano',
|
|
pipelineStage: 'fotos_subidas',
|
|
estado: 'nuevo',
|
|
presupuestoEstimado: null,
|
|
transcripcion: null,
|
|
entidades: null,
|
|
renderUrl: null,
|
|
pdfUrl: null,
|
|
audioUrl: null,
|
|
fotos: ['/antes-bano.webp'],
|
|
daysAgo: 0,
|
|
},
|
|
{
|
|
// 3. Pre-llamada enviada, pendiente de descolgar. Siguiente: llamada agente.
|
|
nombre: 'Marta Ruiz',
|
|
telefono: '+34 655 740 213',
|
|
email: 'marta.ruiz@example.com',
|
|
provincia: 'Sevilla',
|
|
tipoReforma: 'comedor',
|
|
pipelineStage: 'prellamada_enviada',
|
|
estado: 'nuevo',
|
|
presupuestoEstimado: null,
|
|
transcripcion: null,
|
|
entidades: null,
|
|
renderUrl: null,
|
|
pdfUrl: null,
|
|
audioUrl: null,
|
|
fotos: ['/antes-comedor.webp'],
|
|
daysAgo: 1,
|
|
},
|
|
{
|
|
// 4. Llamada completada. Siguiente: render IA.
|
|
nombre: 'Andrés Gil',
|
|
telefono: '+34 677 552 008',
|
|
email: 'andres.gil@example.com',
|
|
provincia: 'Málaga',
|
|
tipoReforma: 'cocina',
|
|
pipelineStage: 'llamada_completada',
|
|
estado: 'nuevo',
|
|
presupuestoEstimado: null,
|
|
transcripcion:
|
|
'Agente: Hola Andrés, te llamo de Reformas Ejemplo. Te aviso de que soy un asistente con IA y de que la llamada se graba. ¿Quieres reformar la cocina entera? Andrés: Sí, son unos 12 metros, quiero quitar un tabique...',
|
|
entidades: {
|
|
espacio: 'cocina',
|
|
m2_aprox: 12,
|
|
tirar_tabique: true,
|
|
calidad: 'media',
|
|
licencia_urbanistica: 'posible',
|
|
},
|
|
renderUrl: null,
|
|
pdfUrl: null,
|
|
audioUrl: '/demo/audio-andres.mp3',
|
|
fotos: ['/antes.webp'],
|
|
daysAgo: 1,
|
|
},
|
|
{
|
|
// 5. Render generado, falta presupuesto+PDF. Siguiente: motor presupuesto.
|
|
nombre: 'Patricia Núñez',
|
|
telefono: '+34 688 410 776',
|
|
email: 'patricia.nunez@example.com',
|
|
provincia: 'Zaragoza',
|
|
tipoReforma: 'bano',
|
|
pipelineStage: 'render_generado',
|
|
estado: 'nuevo',
|
|
presupuestoEstimado: null,
|
|
transcripcion:
|
|
'Agente: Hola Patricia... Patricia: Quiero cambiar la bañera por un plato de ducha y alicatar todo.',
|
|
entidades: {
|
|
espacio: 'bano',
|
|
m2_aprox: 6,
|
|
banera_por_ducha: true,
|
|
alicatado_completo: true,
|
|
calidad: 'media',
|
|
},
|
|
renderUrl: '/despues-bano.webp',
|
|
pdfUrl: null,
|
|
audioUrl: '/demo/audio-patricia.mp3',
|
|
fotos: ['/antes-bano.webp'],
|
|
daysAgo: 2,
|
|
},
|
|
{
|
|
// 6. Presupuesto + PDF listos, sin entregar aún. Siguiente: WhatsApp.
|
|
nombre: 'Roberto Salas',
|
|
telefono: '+34 699 320 145',
|
|
email: 'roberto.salas@example.com',
|
|
provincia: 'Barcelona',
|
|
tipoReforma: 'integral',
|
|
pipelineStage: 'presupuesto_generado',
|
|
estado: 'nuevo',
|
|
presupuestoEstimado: euros(28400),
|
|
transcripcion:
|
|
'Agente: Hola Roberto... Roberto: Es un piso de 70 metros, lo quiero reformar entero, suelo, baño, cocina y pintura.',
|
|
entidades: {
|
|
espacio: 'integral',
|
|
m2_aprox: 70,
|
|
incluye: ['suelo', 'bano', 'cocina', 'pintura'],
|
|
calidad: 'media',
|
|
},
|
|
renderUrl: '/despues.webp',
|
|
pdfUrl: '/demo/presupuesto-roberto.pdf',
|
|
audioUrl: '/demo/audio-roberto.mp3',
|
|
fotos: ['/antes.webp', '/antes-comedor.webp'],
|
|
daysAgo: 2,
|
|
},
|
|
{
|
|
// 7. Entregado al cliente por WhatsApp. Lead caliente recién llegado al panel.
|
|
nombre: 'Elena Castro',
|
|
telefono: '+34 600 781 459',
|
|
email: 'elena.castro@example.com',
|
|
provincia: 'Madrid',
|
|
tipoReforma: 'cocina',
|
|
pipelineStage: 'whatsapp_entregado',
|
|
estado: 'nuevo',
|
|
presupuestoEstimado: euros(15600),
|
|
transcripcion:
|
|
'Agente: Hola Elena... Elena: Quiero renovar la cocina, muebles nuevos y encimera de cuarzo.',
|
|
entidades: {
|
|
espacio: 'cocina',
|
|
m2_aprox: 10,
|
|
muebles_nuevos: true,
|
|
encimera: 'cuarzo',
|
|
calidad: 'media-alta',
|
|
},
|
|
renderUrl: '/despues.webp',
|
|
pdfUrl: '/demo/presupuesto-elena.pdf',
|
|
audioUrl: '/demo/audio-elena.mp3',
|
|
fotos: ['/antes.webp'],
|
|
daysAgo: 3,
|
|
},
|
|
{
|
|
// Entregado y ya contactado por el reformista. Siguiente: agendar visita.
|
|
nombre: 'Tomás Herrero',
|
|
telefono: '+34 611 902 334',
|
|
email: 'tomas.herrero@example.com',
|
|
provincia: 'Bilbao',
|
|
tipoReforma: 'bano',
|
|
pipelineStage: 'whatsapp_entregado',
|
|
estado: 'contactado',
|
|
presupuestoEstimado: euros(9800),
|
|
transcripcion: 'Agente: Hola Tomás... Tomás: Solo el baño, cambiar todo el alicatado y sanitarios.',
|
|
entidades: { espacio: 'bano', m2_aprox: 5, sanitarios_nuevos: true, calidad: 'media' },
|
|
renderUrl: '/despues-bano.webp',
|
|
pdfUrl: '/demo/presupuesto-tomas.pdf',
|
|
audioUrl: '/demo/audio-tomas.mp3',
|
|
fotos: ['/antes-bano.webp'],
|
|
daysAgo: 5,
|
|
},
|
|
{
|
|
// Visita agendada. Siguiente: hacer la visita y enviar presupuesto firmado.
|
|
nombre: 'Carmen Ibáñez',
|
|
telefono: '+34 622 145 870',
|
|
email: 'carmen.ibanez@example.com',
|
|
provincia: 'Madrid',
|
|
tipoReforma: 'comedor',
|
|
pipelineStage: 'whatsapp_entregado',
|
|
estado: 'visita_agendada',
|
|
presupuestoEstimado: euros(7200),
|
|
transcripcion: 'Agente: Hola Carmen... Carmen: Quiero abrir el comedor al salón y poner tarima.',
|
|
entidades: { espacio: 'comedor', m2_aprox: 20, tarima: true, calidad: 'media' },
|
|
renderUrl: '/despues-comedor.webp',
|
|
pdfUrl: '/demo/presupuesto-carmen.pdf',
|
|
audioUrl: '/demo/audio-carmen.mp3',
|
|
fotos: ['/antes-comedor.webp'],
|
|
daysAgo: 8,
|
|
},
|
|
{
|
|
// Ganado: precio final firmado, alimenta precision_history. RF-D-04.
|
|
nombre: 'Diego Romero',
|
|
telefono: '+34 633 770 218',
|
|
email: 'diego.romero@example.com',
|
|
provincia: 'Valencia',
|
|
tipoReforma: 'integral',
|
|
pipelineStage: 'whatsapp_entregado',
|
|
estado: 'ganado',
|
|
presupuestoEstimado: euros(31000),
|
|
transcripcion: 'Agente: Hola Diego... Diego: Reforma integral de un piso de 85 metros heredado.',
|
|
entidades: { espacio: 'integral', m2_aprox: 85, calidad: 'alta' },
|
|
renderUrl: '/despues.webp',
|
|
pdfUrl: '/demo/presupuesto-diego.pdf',
|
|
audioUrl: '/demo/audio-diego.mp3',
|
|
fotos: ['/antes.webp'],
|
|
daysAgo: 25,
|
|
precioFinal: euros(33500),
|
|
},
|
|
{
|
|
// Perdido: el cliente no siguió adelante.
|
|
nombre: 'Sara Blanco',
|
|
telefono: '+34 644 318 092',
|
|
email: 'sara.blanco@example.com',
|
|
provincia: 'Sevilla',
|
|
tipoReforma: 'cocina',
|
|
pipelineStage: 'whatsapp_entregado',
|
|
estado: 'perdido',
|
|
presupuestoEstimado: euros(14200),
|
|
transcripcion: 'Agente: Hola Sara... Sara: Quería una idea de precio para la cocina.',
|
|
entidades: { espacio: 'cocina', m2_aprox: 9, calidad: 'media' },
|
|
renderUrl: '/despues.webp',
|
|
pdfUrl: '/demo/presupuesto-sara.pdf',
|
|
audioUrl: '/demo/audio-sara.mp3',
|
|
fotos: ['/antes.webp'],
|
|
daysAgo: 18,
|
|
},
|
|
];
|
|
|
|
// Orden cronológico del funnel para reconstruir el timeline de cada lead.
|
|
const STAGE_ORDER = schema.pipelineStage.enumValues;
|
|
|
|
async function main() {
|
|
const [existing] = await db
|
|
.select()
|
|
.from(schema.tenants)
|
|
.where(eq(schema.tenants.slug, 'reformas-ejemplo'))
|
|
.limit(1);
|
|
if (existing && !process.env.SEED_FORCE) {
|
|
console.log('Ya hay datos (tenant "reformas-ejemplo"). Saltando seed. Usa SEED_FORCE=1 para forzar.');
|
|
await client.end();
|
|
return;
|
|
}
|
|
|
|
console.log('Limpiando datos previos...');
|
|
await db.execute(
|
|
sql`TRUNCATE TABLE ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.leads}, ${schema.tenants} RESTART IDENTITY CASCADE`
|
|
);
|
|
|
|
console.log('Creando tenant "Reformas Ejemplo"...');
|
|
const [tenant] = await db
|
|
.insert(schema.tenants)
|
|
.values({
|
|
slug: 'reformas-ejemplo',
|
|
nombreEmpresa: 'Reformas Ejemplo',
|
|
provincia: 'Madrid',
|
|
whatsappBusiness: '+34 600 000 000',
|
|
})
|
|
.returning();
|
|
|
|
console.log(`Insertando ${SEED_LEADS.length} leads...`);
|
|
for (const l of SEED_LEADS) {
|
|
const createdAt = new Date(Date.now() - l.daysAgo * 24 * 60 * 60 * 1000);
|
|
|
|
const [lead] = await db
|
|
.insert(schema.leads)
|
|
.values({
|
|
tenantId: tenant.id,
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
nombre: l.nombre,
|
|
telefono: l.telefono,
|
|
email: l.email,
|
|
provincia: l.provincia,
|
|
tipoReforma: l.tipoReforma,
|
|
consentPrivacidad: true,
|
|
consentContratacion: true,
|
|
pipelineStage: l.pipelineStage,
|
|
estado: l.estado,
|
|
presupuestoEstimado: l.presupuestoEstimado,
|
|
transcripcion: l.transcripcion,
|
|
entidades: l.entidades,
|
|
renderUrl: l.renderUrl,
|
|
pdfUrl: l.pdfUrl,
|
|
audioUrl: l.audioUrl,
|
|
})
|
|
.returning();
|
|
|
|
// Fotos
|
|
if (l.fotos.length) {
|
|
await db.insert(schema.leadFotos).values(
|
|
l.fotos.map((url, orden) => ({ leadId: lead.id, url, orden }))
|
|
);
|
|
}
|
|
|
|
// Timeline del funnel: un evento por cada paso alcanzado hasta el actual.
|
|
const reached = STAGE_ORDER.slice(0, STAGE_ORDER.indexOf(l.pipelineStage) + 1);
|
|
await db.insert(schema.leadPipelineEventos).values(
|
|
reached.map((stage, i) => ({
|
|
leadId: lead.id,
|
|
stage,
|
|
occurredAt: new Date(createdAt.getTime() + i * 5 * 60 * 1000),
|
|
}))
|
|
);
|
|
|
|
// Histórico de estado (siempre nace en 'nuevo')
|
|
const estados: (typeof schema.leadEstado.enumValues)[number][] =
|
|
l.estado === 'nuevo' ? ['nuevo'] : ['nuevo', l.estado];
|
|
await db.insert(schema.leadEstadoHistory).values(
|
|
estados.map((estado, i) => ({
|
|
leadId: lead.id,
|
|
estado,
|
|
changedAt: new Date(createdAt.getTime() + i * 60 * 60 * 1000),
|
|
}))
|
|
);
|
|
|
|
// Precisión para ganados (RF-D-04)
|
|
if (l.estado === 'ganado' && l.precioFinal && l.presupuestoEstimado) {
|
|
const deltaPct = ((l.precioFinal - l.presupuestoEstimado) / l.presupuestoEstimado) * 100;
|
|
await db.insert(schema.precisionHistory).values({
|
|
leadId: lead.id,
|
|
estimated: l.presupuestoEstimado,
|
|
final: l.precioFinal,
|
|
deltaPct: deltaPct.toFixed(2),
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Precios + catálogo demo (motor de presupuesto) ---
|
|
const [tenantRow] = await db
|
|
.select()
|
|
.from(schema.tenants)
|
|
.where(eq(schema.tenants.slug, 'reformas-ejemplo'))
|
|
.limit(1);
|
|
|
|
if (tenantRow) {
|
|
await db.delete(schema.catalogItems).where(eq(schema.catalogItems.tenantId, tenantRow.id));
|
|
await db.delete(schema.pricingConfig).where(eq(schema.pricingConfig.tenantId, tenantRow.id));
|
|
|
|
await db.insert(schema.pricingConfig).values({
|
|
tenantId: tenantRow.id,
|
|
alturaTechoDefault: 2.5,
|
|
factorZona: { Madrid: 1.1, Barcelona: 1.15, Valencia: 1.0, Sevilla: 0.95 },
|
|
manoObra: { demolicion: 1800, fontaneria: 2200, electricidad: 1600, mano_de_obra: 3500 },
|
|
});
|
|
|
|
const cat = (
|
|
categoria: 'suelo' | 'pared' | 'pintura' | 'mobiliario',
|
|
nombre: string,
|
|
calidad: 'basica' | 'media' | 'premium',
|
|
precioEuros: number,
|
|
unidad: 'm2' | 'ml' | 'ud',
|
|
descriptorRender: string,
|
|
sku: string,
|
|
) => ({
|
|
tenantId: tenantRow.id,
|
|
categoria,
|
|
nombre,
|
|
calidad,
|
|
precioUnit: Math.round(precioEuros * 100),
|
|
unidad,
|
|
descriptorRender,
|
|
esDefault: true,
|
|
sku,
|
|
});
|
|
|
|
await db.insert(schema.catalogItems).values([
|
|
cat('suelo', 'Gres cerámico básico', 'basica', 16, 'm2', 'suelo gres beige liso', 'SUE-B'),
|
|
cat('suelo', 'Porcelánico símil madera', 'media', 28, 'm2', 'porcelánico símil roble claro', 'SUE-M'),
|
|
cat('suelo', 'Porcelánico gran formato', 'premium', 48, 'm2', 'porcelánico gran formato gris piedra', 'SUE-P'),
|
|
cat('pared', 'Azulejo blanco brillo', 'basica', 14, 'm2', 'azulejo blanco brillo 20x20', 'PAR-B'),
|
|
cat('pared', 'Azulejo rectificado', 'media', 24, 'm2', 'azulejo rectificado blanco mate 30x60', 'PAR-M'),
|
|
cat('pared', 'Porcelánico decorativo', 'premium', 42, 'm2', 'porcelánico decorativo símil mármol', 'PAR-P'),
|
|
cat('pintura', 'Plástica mate', 'basica', 6, 'm2', 'pintura plástica blanca mate', 'PIN-B'),
|
|
cat('pintura', 'Plástica lavable', 'media', 9, 'm2', 'pintura lavable blanco roto', 'PIN-M'),
|
|
cat('pintura', 'Esmalte premium', 'premium', 14, 'm2', 'esmalte al agua acabado seda gris perla', 'PIN-P'),
|
|
cat('mobiliario', 'Muebles melamina', 'basica', 180, 'ml', 'muebles cocina melamina blanca', 'MOB-B'),
|
|
cat('mobiliario', 'Muebles laminado', 'media', 320, 'ml', 'muebles cocina laminado roble con tirador integrado', 'MOB-M'),
|
|
cat('mobiliario', 'Muebles lacado', 'premium', 550, 'ml', 'muebles cocina lacado mate antracita y encimera porcelánica', 'MOB-P'),
|
|
]);
|
|
|
|
// Inputs demo en un lead ya avanzado para poder recalcular su presupuesto.
|
|
await db
|
|
.update(schema.leads)
|
|
.set({ m2Suelo: 12, calidadGlobal: 'media', estructural: false })
|
|
.where(eq(schema.leads.email, 'roberto.salas@example.com'));
|
|
}
|
|
|
|
console.log('Seed completado.');
|
|
await client.end();
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|