import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { PrismaClient } from "@prisma/client"; import { hashSync } from "bcryptjs"; import { serializeClases, type ClaseFuego } from "../src/data/clases-fuego"; import { getProducto } from "../src/data/catalogo"; import { INDUSTRIAS } from "../src/data/industrias"; import { IVA, MOTIVOS_PERDIDA } from "../src/lib/crm/constantes"; /** * Datos de demostración del micro CRM. * * Es idempotente: borra y vuelve a construir. Todo sale de arreglos propios y * de un generador congruencial con semilla fija, así que dos corridas producen * exactamente la misma base y las capturas de pantalla de la demo no cambian * de una sesión a otra. * * Nada de esto es información real de FSPM ni de sus clientes: las empresas, * las personas, las campañas y los precios son inventados para la demostración. */ // ---------- entorno ---------- /** * `npm run db:seed` ejecuta tsx directamente, sin la carga de .env que hace la * CLI de Prisma. Se lee a mano para que DATABASE_URL exista igual. */ function cargarEnv(): void { if (process.env.DATABASE_URL) return; const ruta = resolve(process.cwd(), ".env"); if (!existsSync(ruta)) return; for (const linea of readFileSync(ruta, "utf8").split(/\r?\n/)) { const limpia = linea.trim(); if (!limpia || limpia.startsWith("#")) continue; const i = limpia.indexOf("="); if (i < 1) continue; const clave = limpia.slice(0, i).trim(); const valor = limpia.slice(i + 1).trim().replace(/^["']|["']$/g, ""); if (!process.env[clave]) process.env[clave] = valor; } } cargarEnv(); const db = new PrismaClient(); // ---------- azar reproducible ---------- /** Congruencial lineal. Semilla fija: el seed tiene que ser reproducible. */ let estado = 20260827; function aleatorio(): number { estado = (estado * 1664525 + 1013904223) % 4294967296; return estado / 4294967296; } function entero(min: number, max: number): number { return min + Math.floor(aleatorio() * (max - min + 1)); } function elige(lista: readonly T[]): T { return lista[Math.floor(aleatorio() * lista.length)]; } function probable(p: number): boolean { return aleatorio() < p; } function porPeso(opciones: readonly { valor: T; peso: number }[]): T { const total = opciones.reduce((s, o) => s + o.peso, 0); let tiro = aleatorio() * total; for (const o of opciones) { tiro -= o.peso; if (tiro <= 0) return o.valor; } return opciones[opciones.length - 1].valor; } const NO_ASCII = /[^\u0020-\u007E]/g; function sinAcentos(v: string): string { return v .normalize("NFD") .replace(NO_ASCII, "") .replace(/[^a-zA-Z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .toLowerCase(); } // ---------- catálogos propios ---------- const NOMBRES = [ "Ana", "Mariana", "Jorge", "Luis", "Fernanda", "Ricardo", "Paulina", "Emilio", "Gabriela", "Héctor", "Alejandra", "Sergio", "Karla", "Ramiro", "Diana", "Óscar", "Verónica", "Ignacio", "Claudia", "Rubén", "Silvia", "Arturo", "Norma", "Joaquín", "Adriana", "Salvador", "Rocío", "Gerardo", "Itzel", "Benjamín", "Lorena", "Efraín", "Mónica", "Tomás", "Elisa", "Aarón", ] as const; const APELLIDOS = [ "Ruiz", "Escobedo", "Terán", "Cárdenas", "Nájera", "Villaseñor", "Zamudio", "Rentería", "Ocampo", "Barajas", "Quintero", "Lomelí", "Arreola", "Bustamante", "Cervantes", "Grajeda", "Íñiguez", "Maldonado", "Peralta", "Rivas", "Sandoval", "Trujillo", "Urbina", "Valadez", "Yáñez", "Zúñiga", "Alcántara", "Berrones", "Contreras", "Delgadillo", "Esquivel", "Fuentes", ] as const; const CIUDADES = [ "Ciudad de México", "Monterrey, N.L.", "Guadalajara, Jal.", "Querétaro, Qro.", "Puebla, Pue.", "Tijuana, B.C.", "Saltillo, Coah.", "León, Gto.", "Mérida, Yuc.", "Toluca, Edo. Méx.", "Villahermosa, Tab.", "Veracruz, Ver.", "Aguascalientes, Ags.", "Hermosillo, Son.", "Culiacán, Sin.", "San Luis Potosí, S.L.P.", "Cancún, Q. Roo", "Chihuahua, Chih.", ] as const; const PUESTOS_POR_INDUSTRIA: Record = { "industria-energia": [ "Jefe de seguridad industrial", "Gerente de planta", "Coordinador de EHS", "Superintendente de operaciones", "Jefe de mantenimiento", ], "logistica-bodegas": [ "Jefe de almacén", "Gerente de logística", "Coordinador de protección civil", "Supervisor de turno", "Gerente de operaciones", ], "gasolineras-flotas": [ "Encargado de estación", "Gerente de flotilla", "Supervisor de patio", "Coordinador de seguridad", ], "cocinas-hoteles": [ "Gerente de alimentos y bebidas", "Chef ejecutivo", "Jefe de mantenimiento", "Gerente general", ], "pymes-oficinas": [ "Administrador", "Dueño", "Coordinador administrativo", "Responsable de protección civil", ], }; /** Empresas ficticias, agrupadas por el segmento al que pertenecen. */ const EMPRESAS_POR_INDUSTRIA: Record = { "industria-energia": [ "Petroquímica del Golfo", "Termoeléctrica Bajío", "Química Industrial Anáhuac", "Aceros del Norte Integrados", "Fundidora Coahuila", "Lubricantes Tepeyac", "Gas Industrial Sonora", "Refinados del Sureste", "Minera Peña Blanca", "Cementos Altiplano", "Papelera del Valle de Toluca", "Plásticos Tecnológicos de México", "Solventes Industriales Bajío", "Energía Renovable del Istmo", ], "logistica-bodegas": [ "Almacenes Centrales del Bajío", "Logística Ferrer y Asociados", "Distribuidora Sinaloense", "Transportes Occidente Unido", "Bodegas Norte Industrial", "Manufacturas Metálicas Torreón", "Empaques Corrugados Puebla", "Centro Logístico Cuautitlán", "Almacenaje Frío Querétaro", "Textiles Aguascalientes", "Autopartes Ensamble Silao", "Cadena Frío Michoacán", ], "gasolineras-flotas": [ "Estaciones de Servicio Rivera", "Combustibles del Pacífico", "Grupo Gasolinero Amanecer", "Autotanques Vega", "Flotilla Pesada Zermeño", "Servicio Carretero El Mezquite", "Gasolineras Unidas de Yucatán", "Maquinaria Pesada Delgado", "Estación Nueva Alborada", "Diésel y Servicios Reynosa", "Grupo Combustible Altamira", ], "cocinas-hoteles": [ "Hotel Casa Marena", "Hotelera Puerto Sereno", "Comedores Industriales Aztlán", "Restaurantes La Higuera", "Banquetes Del Bosque", "Hospedaje Boutique Alameda", "Cocinas Centrales Metepec", "Grupo Gastronómico Serrano", "Hotel Mirador Real", "Cafeterías Corporativas Nogal", "Marisquería Puerto Nuevo", ], "pymes-oficinas": [ "Contadores Aguilar y Herrera", "Refacciones Automotrices Beltrán", "Papelería Escolar Progreso", "Clínica Dental Sonrisa Viva", "Despacho Jurídico Nava", "Ferretería El Yunque", "Farmacias Buen Camino", "Estudio de Diseño Cardinal", "Servicios Contables Zamora", "Óptica Visión Clara", "Panadería La Espiga de Oro", "Taller Mecánico Ríos", "Imprenta Digital Meridiano", "Laboratorio Clínico Vitalis", ], }; /** Productos verosímiles por segmento. Slugs reales de src/data/catalogo.ts. */ const PRODUCTOS_POR_INDUSTRIA: Record = { "industria-energia": [ "fireade-2000", "flameade-retardante", "enforcer-60", "carretilla-100-litros", "carretilla-70-litros", "firemiks", "enforcer-robot", "trakade", "vaporade-plus", ], "logistica-bodegas": [ "carretilla-70-litros", "carretilla-50-litros", "enforcer-12-litros", "esfera-con-detector-de-humo", "esfera-rociadora-automatica", "enforcer-10", "enforcer-robot", ], "gasolineras-flotas": [ "carretilla-100-litros", "carretilla-50-litros", "carretilla-30-litros", "enforcer-12-litros", "enforcer-9-litros", "fireade-2000", "enforcer-60", ], "cocinas-hoteles": [ "esfera-rociadora-automatica", "esfera-con-detector-de-humo", "enforcer-6-litros", "enforcer-4-litros", "enforcer-10", ], "pymes-oficinas": [ "enforcer-4-litros", "enforcer-2-litros", "enforcer-1-litro", "enforcer-6-litros", "esfera-sin-detector-de-humo", "receptor-sarmex", ], }; const SUPERFICIES_POR_INDUSTRIA: Record = { "industria-energia": [ "Patio de tanques de 3 200 m² más zona de trasvase", "Nave de proceso de 6 800 m² en dos niveles", "Planta completa, 14 hectáreas con cuatro áreas de riesgo", "Cuarto eléctrico y subestación, 480 m²", ], "logistica-bodegas": [ "Nave de 28 000 m² con racks a 11 m", "Dos naves de 9 500 m² cada una", "Centro de distribución de 42 000 m²", "Bodega de 6 200 m² más andén de carga", ], "gasolineras-flotas": [ "Cuatro islas, ocho posiciones de carga", "Estación con tres islas y patio de autotanques", "Patio de maniobras de 1 800 m² con 22 unidades", "Dos estaciones en el mismo municipio", ], "cocinas-hoteles": [ "Cocina central de 340 m² con tres campanas", "Hotel de 96 habitaciones y dos cocinas", "Comedor industrial para 800 comensales por turno", "Cocina de 180 m² con seis freidoras", ], "pymes-oficinas": [ "Oficina de 240 m² en un piso", "Local comercial de 120 m²", "Taller de 380 m² más oficina", "Dos sucursales de 90 m² cada una", ], }; const MENSAJES_POR_INDUSTRIA: Record = { "industria-energia": [ "Nos levantaron observación en la última auditoría por el tiempo de respuesta en el patio de tanques. Necesitamos el escenario documentado y el equipo que corresponde.", "Manejamos solventes y aceite térmico. La aseguradora nos pidió evidencia del agente utilizado y de la capacitación de la brigada.", "Estamos ampliando una línea y el área nueva quedó sin cobertura. Queremos el diagnóstico antes de arrancar producción.", "Buscamos reemplazar el polvo químico por espuma en la zona de trasvase. Necesitamos comparar agentes y costo de mantenimiento.", ], "logistica-bodegas": [ "Tenemos racks a once metros y el equipo actual no alcanza. Queremos revisar la distribución por distancia real de recorrido.", "Cargamos baterías de montacargas dentro de la nave y nos preocupa el riesgo eléctrico. Necesitamos recomendación técnica.", "Vamos a certificar el centro de distribución y nos falta el expediente de protección contra incendios.", "Nos cambiaron el giro de la carga almacenada y hay que reclasificar el riesgo de incendio de la nave.", ], "gasolineras-flotas": [ "Protección Civil municipal nos pidió equipo operable por el despachador. Queremos cotizar la solución móvil.", "Tuvimos un conato en la isla dos. El personal no supo usar el extintor. Necesitamos equipo y capacitación.", "Abrimos una estación nueva y hay que dejar el expediente completo desde el arranque.", "Tenemos autotanques dentro del predio y el equipo actual no cubre ese escenario.", ], "cocinas-hoteles": [ "Tenemos seis freidoras bajo la misma campana y no hay supresión automática. Nos urge la propuesta.", "La verificación viene el mes que entra y la cocina es el punto que traemos débil.", "Queremos solución para clase K con demostración al personal del turno de la noche.", "El ducto de extracción tiene años sin programa formal de limpieza y queremos cerrar el riesgo completo.", ], "pymes-oficinas": [ "Necesitamos cumplir con la NOM-002 sin comprar de más. ¿Qué nos corresponde por superficie?", "Nos pidieron el plan de emergencia por escrito y no sabemos con qué equipo debemos contar.", "Vamos a abrir una segunda sucursal y queremos dejar las dos en orden.", "Nuestros extintores llevan más de un año sin revisión y no sabemos si son los adecuados.", ], }; /** Etiquetas del selector de motivo del widget de WhatsApp. */ const MOTIVOS_WHATSAPP = [ "Quiero un diagnóstico de mi instalación", "Necesito cotizar equipo", "Tengo una verificación o auditoría encima", "Es urgente, tuve un incidente", "Otro tema", ] as const; /** * Campañas de la demostración. Cada una tiene su propia tasa de cierre para * que la analítica cuente algo: la campaña de cumplimiento normativo convierte * mucho mejor que el remarketing social, que trae volumen barato y frío. */ type Campana = { utmSource: string | null; utmMedium: string | null; utmCampaign: string | null; referrer: string | null; landingPath: string; peso: number; /** Probabilidad de terminar en GANADA. */ cierre: number; gclid?: boolean; fbclid?: boolean; }; const CAMPANAS: Campana[] = [ { utmSource: "google", utmMedium: "cpc", utmCampaign: "nom-002-cumplimiento", referrer: "https://www.google.com/", landingPath: "/cumplimiento-nom-002", peso: 20, cierre: 0.34, gclid: true, }, { utmSource: "google", utmMedium: "cpc", utmCampaign: "extintor-gasolinera", referrer: "https://www.google.com/", landingPath: "/soluciones/gasolineras-flotas", peso: 11, cierre: 0.2, gclid: true, }, { utmSource: "google", utmMedium: "cpc", utmCampaign: "espuma-sin-fluor", referrer: "https://www.google.com/", landingPath: "/producto/fireade-2000", peso: 12, cierre: 0.26, gclid: true, }, { utmSource: "meta", utmMedium: "paid_social", utmCampaign: "remarketing-pymes", referrer: "https://l.facebook.com/", landingPath: "/soluciones/pymes-oficinas", peso: 16, cierre: 0.07, fbclid: true, }, { utmSource: "google", utmMedium: "organic", utmCampaign: null, referrer: "https://www.google.com/", landingPath: "/evidencia", peso: 14, cierre: 0.16, }, { utmSource: "linkedin", utmMedium: "social", utmCampaign: "continuidad-operativa", referrer: "https://www.linkedin.com/", landingPath: "/soluciones/industria-energia", peso: 8, cierre: 0.24, }, { utmSource: "referido", utmMedium: "referral", utmCampaign: "socio-comercial", referrer: "https://www.camaraindustrial-demo.mx/proveedores", landingPath: "/nosotros", peso: 7, cierre: 0.3, }, { // tráfico directo: sin UTM y sin referrer. La analítica lo agrupa aparte. utmSource: null, utmMedium: null, utmCampaign: null, referrer: null, landingPath: "/", peso: 12, cierre: 0.12, }, ]; /** * Precios de demostración. No son la lista de precios de FSPM: son cifras * inventadas, del orden de magnitud del giro, para que los pedidos de la demo * sumen cantidades creíbles. No usarlos para cotizar. */ const PRECIOS_DEMO: Record = { "enforcer-1-litro": 1290, "enforcer-2-litros": 1890, "enforcer-4-litros": 2790, "enforcer-6-litros": 3450, "enforcer-9-litros": 4290, "enforcer-12-litros": 5190, "enforcer-10": 18500, "esfera-sin-detector-de-humo": 6900, "esfera-con-detector-de-humo": 9800, "carretilla-30-litros": 14500, "carretilla-50-litros": 19800, "carretilla-70-litros": 26400, "carretilla-100-litros": 34900, "enforcer-60": 42500, "esfera-rociadora-automatica": 7600, "fireade-2000": 4850, "flameade-retardante": 3980, trakade: 3450, "vaporade-plus": 4200, firemiks: 128000, "receptor-sarmex": 8900, "enforcer-robot": 315000, }; const VALOR_POR_INDUSTRIA: Record = { "industria-energia": [180_000, 1_400_000], "logistica-bodegas": [90_000, 620_000], "gasolineras-flotas": [48_000, 265_000], "cocinas-hoteles": [62_000, 340_000], "pymes-oficinas": [12_000, 92_000], }; // ---------- tiempo ---------- const DIA_MS = 86_400_000; const AHORA = new Date(); function inicioDeDia(f: Date): Date { const d = new Date(f); d.setHours(0, 0, 0, 0); return d; } const DIA_CERO = new Date(inicioDeDia(AHORA).getTime() - 55 * DIA_MS); /** Días de la ventana con su peso: mucho en día hábil, casi nada en domingo. */ const DIAS_PONDERADOS: { valor: number; peso: number }[] = []; for (let i = 0; i < 56; i += 1) { const dia = new Date(DIA_CERO.getTime() + i * DIA_MS).getDay(); const peso = dia === 0 ? 1 : dia === 6 ? 4 : 20; DIAS_PONDERADOS.push({ valor: i, peso }); } const HORAS_PONDERADAS: { valor: number; peso: number }[] = [ { valor: 8, peso: 4 }, { valor: 9, peso: 9 }, { valor: 10, peso: 14 }, { valor: 11, peso: 15 }, { valor: 12, peso: 13 }, { valor: 13, peso: 9 }, { valor: 14, peso: 6 }, { valor: 15, peso: 8 }, { valor: 16, peso: 12 }, { valor: 17, peso: 11 }, { valor: 18, peso: 7 }, { valor: 19, peso: 3 }, ]; function fechaDeEnvio(): Date { const offset = porPeso(DIAS_PONDERADOS); const hora = porPeso(HORAS_PONDERADAS); return new Date( DIA_CERO.getTime() + offset * DIA_MS + hora * 3_600_000 + entero(0, 59) * 60_000, ); } /** Minutos disponibles entre una fecha y este momento. */ function minutosHastaAhora(desde: Date): number { return Math.floor((AHORA.getTime() - desde.getTime()) / 60_000); } /** * Fecha posterior a `desde` que no se sale del presente. Si no cabe ni el * mínimo, devuelve null: un lead de hace media hora no puede llevar veinte * horas de atendido. */ function despuesDe(desde: Date, minutos: number, minimo = 20): Date | null { const disponibles = minutosHastaAhora(desde); if (disponibles < minimo) return null; const usados = Math.max(Math.min(minutos, Math.floor(disponibles * 0.8)), minimo); return new Date(desde.getTime() + usados * 60_000); } // ---------- estructuras en memoria ---------- type PlanContacto = { indice: number; nombre: string; email: string; telefono: string; telefonoNorm: string; empresa: string; puesto: string; ciudad: string; industria: string; cuantas: number; }; type PlanOportunidad = { contacto: number; origen: "FORMULARIO" | "WHATSAPP"; canal: string; etapa: string; prioridad: string; industria: string | null; productoSlug: string | null; productoNombre: string | null; categoriaSlug: string | null; productoUrl: string | null; clases: string | null; superficie: string | null; urgencia: string | null; mensaje: string; valorEstimado: number | null; campana: Campana; dispositivo: string; creadoEn: Date; primeraRespuestaEn: Date | null; cerradoEn: Date | null; motivoPerdida: string | null; propietarioIndice: number; }; // ---------- construcción ---------- async function main(): Promise { // 1. limpieza en orden seguro para las llaves foráneas await db.activity.deleteMany(); await db.order.deleteMany(); await db.opportunity.deleteMany(); await db.contact.deleteMany(); await db.assignmentRule.deleteMany(); await db.user.deleteMany(); await db.counter.deleteMany(); // 2. usuarios const CLAVE = "Fspm2026!"; const hashClave = hashSync(CLAVE, 10); const usuariosPlan = [ { nombre: "Rodrigo Villaseñor", email: "admin@fspm.com.mx", rol: "ADMIN", puesto: "Dirección comercial", telefono: "+52 55 4034 8300", }, { nombre: "Mariana Escobedo", email: "mariana.escobedo@fspm.com.mx", rol: "VENDEDOR", puesto: "Especialista en industria y energía", telefono: "+52 55 4034 8301", }, { nombre: "Luis Ángel Terán", email: "luis.teran@fspm.com.mx", rol: "VENDEDOR", puesto: "Especialista en logística y bodegas", telefono: "+52 55 4034 8302", }, { nombre: "Paulina Cárdenas", email: "paulina.cardenas@fspm.com.mx", rol: "VENDEDOR", puesto: "Especialista en cocinas, hoteles y PyMEs", telefono: "+52 55 4034 8303", }, { nombre: "Emilio Nájera", email: "emilio.najera@fspm.com.mx", rol: "VENDEDOR", puesto: "Respuesta rápida y estaciones de servicio", telefono: "+52 55 4034 8304", }, ] as const; const usuarios: { id: string; nombre: string }[] = []; for (const [i, u] of usuariosPlan.entries()) { const creado = await db.user.create({ data: { nombre: u.nombre, email: u.email, passwordHash: hashClave, rol: u.rol, puesto: u.puesto, telefono: u.telefono, activo: true, creadoEn: new Date(DIA_CERO.getTime() - (200 - i * 10) * DIA_MS), }, select: { id: true, nombre: true }, }); usuarios.push(creado); } const ADMIN = 0; const MARIANA = 1; const LUIS = 2; const PAULINA = 3; const EMILIO = 4; // 3. reglas de asignación const reglasPlan = [ { nombre: "Industria y energía", prioridad: 10, tipo: "INDUSTRIA", valor: "industria-energia", u: MARIANA }, { nombre: "Logística y bodegas", prioridad: 20, tipo: "INDUSTRIA", valor: "logistica-bodegas", u: LUIS }, { nombre: "Gasolineras y flotas", prioridad: 30, tipo: "INDUSTRIA", valor: "gasolineras-flotas", u: EMILIO }, { nombre: "Cocinas y hoteles", prioridad: 40, tipo: "INDUSTRIA", valor: "cocinas-hoteles", u: PAULINA }, { nombre: "WhatsApp a respuesta rápida", prioridad: 50, tipo: "ORIGEN", valor: "WHATSAPP", u: EMILIO }, { nombre: "Respaldo general", prioridad: 900, tipo: "CUALQUIERA", valor: null, u: PAULINA }, ] as const; for (const r of reglasPlan) { await db.assignmentRule.create({ data: { nombre: r.nombre, prioridad: r.prioridad, tipo: r.tipo, valor: r.valor, usuarioId: usuarios[r.u].id, activa: true, creadoEn: new Date(DIA_CERO.getTime() - 120 * DIA_MS), }, }); } /** Réplica del motor de asignación, con la tabla de reglas de arriba. */ function asignarSeed(industria: string | null, origen: string): number { if (industria === "industria-energia") return MARIANA; if (industria === "logistica-bodegas") return LUIS; if (industria === "gasolineras-flotas") return EMILIO; if (industria === "cocinas-hoteles") return PAULINA; if (origen === "WHATSAPP") return EMILIO; return PAULINA; } // 4. contactos en memoria const slugsIndustria = INDUSTRIAS.map((i) => i.slug); const clasesDeIndustria = new Map( INDUSTRIAS.map((i) => [i.slug, [...i.clases]]), ); const TOTAL_CONTACTOS = 60; // 10 contactos con 3 oportunidades, 15 con 2 y 35 con 1 = 95 oportunidades. // Los repetidos son la prueba de que el CRM acumula historia por persona. const cuantasPorContacto: number[] = [ ...Array(10).fill(3), ...Array(15).fill(2), ...Array(35).fill(1), ]; const usadosEmail = new Set(); const contactos: PlanContacto[] = []; for (let i = 0; i < TOTAL_CONTACTOS; i += 1) { const industria = slugsIndustria[i % slugsIndustria.length]; const empresasDelGiro = EMPRESAS_POR_INDUSTRIA[industria]; const empresa = empresasDelGiro[Math.floor(i / slugsIndustria.length) % empresasDelGiro.length]; const nombre = `${elige(NOMBRES)} ${elige(APELLIDOS)}`; const dominio = sinAcentos(empresa).split("-").slice(0, 2).join(""); let email = `${sinAcentos(nombre).replace("-", ".")}@${dominio}.com.mx`; let n = 2; while (usadosEmail.has(email)) { email = `${sinAcentos(nombre).replace("-", ".")}${n}@${dominio}.com.mx`; n += 1; } usadosEmail.add(email); const lada = elige(["55", "81", "33", "442", "222", "664", "999"] as const); const resto = String(4_000_000 + i * 13_577).slice(0, 10 - lada.length); const digitos = `${lada}${resto}`.slice(0, 10); contactos.push({ indice: i, nombre, email, telefono: `+52 ${lada} ${resto.slice(0, 4)} ${resto.slice(4)}`.trim(), telefonoNorm: digitos, empresa, puesto: elige(PUESTOS_POR_INDUSTRIA[industria]), ciudad: elige(CIUDADES), industria, cuantas: cuantasPorContacto[i], }); } // 5. oportunidades en memoria const campanasPonderadas = CAMPANAS.map((c) => ({ valor: c, peso: c.peso })); const oportunidades: PlanOportunidad[] = []; for (const c of contactos) { const fechas = Array.from({ length: c.cuantas }, () => fechaDeEnvio()).sort( (a, b) => a.getTime() - b.getTime(), ); for (let k = 0; k < c.cuantas; k += 1) { // el primer contacto de una persona siempre entra por formulario; el // widget de WhatsApp aparece en los reenvíos, como pasa en la realidad const porWhatsApp = k > 0 && probable(0.45); const campana = porPeso(campanasPonderadas); const industriaOp = porWhatsApp ? null : c.industria; const productoSlug = porWhatsApp ? null : probable(0.72) ? elige(PRODUCTOS_POR_INDUSTRIA[c.industria]) : null; const producto = productoSlug ? getProducto(productoSlug) : undefined; const clasesBase = clasesDeIndustria.get(c.industria) ?? []; const clases = porWhatsApp ? [] : clasesBase.filter((cl) => (c.industria === "cocinas-hoteles" && cl === "K") || probable(0.75)); if (!porWhatsApp && clases.length === 0 && clasesBase.length > 0) clases.push(clasesBase[0]); const urgencia = porWhatsApp ? probable(0.3) ? "inmediata" : null : porPeso([ { valor: "inmediata", peso: 18 }, { valor: "trimestre", peso: 30 }, { valor: "presupuesto", peso: 32 }, { valor: "explorando", peso: 20 }, ]); const canal = porWhatsApp ? "widget" : producto ? "producto" : porPeso([ { valor: "home", peso: 18 }, { valor: "catalogo", peso: 14 }, { valor: "solucion", peso: 24 }, { valor: "contacto", peso: 22 }, { valor: "cumplimiento", peso: 22 }, ]); const mensaje = porWhatsApp ? elige(MOTIVOS_WHATSAPP) : elige(MENSAJES_POR_INDUSTRIA[c.industria]); // Etapa del embudo abierto, con pesos que se angostan hacia arriba. Las // ganadas no se sortean aquí: se reparten después por cuota de campaña, // para que la tasa de cierre de cada una sea exacta y no un accidente // del muestreo. const etapa = porPeso([ { valor: "NUEVO", peso: 40 }, { valor: "CONTACTADO", peso: 30 }, { valor: "DIAGNOSTICO", peso: 19 }, { valor: "COTIZACION", peso: 21 }, { valor: "PERDIDA", peso: 14 }, ]); const prioridad = urgencia === "inmediata" || porWhatsApp ? "ALTA" : urgencia === "explorando" ? "BAJA" : "MEDIA"; const [minValor, maxValor] = VALOR_POR_INDUSTRIA[c.industria]; const conValor = etapa !== "NUEVO" && (etapa !== "CONTACTADO" || probable(0.45)); const valorEstimado = conValor ? Math.round(entero(minValor, maxValor) / 500) * 500 : null; oportunidades.push({ contacto: c.indice, origen: porWhatsApp ? "WHATSAPP" : "FORMULARIO", canal, etapa, prioridad, industria: industriaOp, productoSlug: producto?.slug ?? null, productoNombre: producto?.nombre ?? null, categoriaSlug: producto?.categoria ?? null, productoUrl: producto ? `/producto/${producto.slug}` : null, clases: clases.length ? serializeClases(clases as ClaseFuego[]) : null, superficie: porWhatsApp ? null : elige(SUPERFICIES_POR_INDUSTRIA[c.industria]), urgencia, mensaje, valorEstimado, campana, dispositivo: porPeso([ { valor: "escritorio", peso: 58 }, { valor: "movil", peso: 34 }, { valor: "tableta", peso: 8 }, ]), creadoEn: fechas[k], primeraRespuestaEn: null, cerradoEn: null, motivoPerdida: null, propietarioIndice: asignarSeed(industriaOp, porWhatsApp ? "WHATSAPP" : "FORMULARIO"), }); } } const DIAS_MINIMOS_CIERRE = 3; // 5b. una oportunidad cerrada necesita historia detrás: si el lead entró // hace menos de tres días se queda en una etapa intermedia, en vez de // inventar un ciclo de venta que no cabe en el calendario const puedeCerrar = (o: PlanOportunidad) => minutosHastaAhora(o.creadoEn) >= DIAS_MINIMOS_CIERRE * 1440; for (const o of oportunidades) { if (o.etapa !== "PERDIDA") continue; if (puedeCerrar(o)) continue; o.etapa = "CONTACTADO"; } /** Mezcla reproducible: Fisher-Yates con el mismo generador con semilla. */ function mezclar(lista: T[]): T[] { const copia = [...lista]; for (let i = copia.length - 1; i > 0; i -= 1) { const j = entero(0, i); [copia[i], copia[j]] = [copia[j], copia[i]]; } return copia; } const ponerValor = (o: PlanOportunidad) => { if (o.valorEstimado !== null) return; const [mn, mx] = VALOR_POR_INDUSTRIA[contactos[o.contacto].industria]; o.valorEstimado = Math.round(entero(mn, mx) / 500) * 500; }; // 5c. cuota de ganadas por campaña. Cada campaña cierra exactamente la // proporción que le corresponde, así que la analítica cuenta una historia // estable: cumplimiento normativo convierte varias veces mejor que el // remarketing social, y eso se ve igual en cada corrida del seed. const ganadas = () => oportunidades.filter((o) => o.etapa === "GANADA"); for (const campana of CAMPANAS) { const suyas = oportunidades.filter((o) => o.campana === campana); const objetivo = Math.round(suyas.length * campana.cierre); const elegibles = mezclar(suyas.filter((o) => o.etapa !== "PERDIDA" && puedeCerrar(o))); for (const o of elegibles.slice(0, objetivo)) { o.etapa = "GANADA"; ponerValor(o); } } // 5c-bis. la demo necesita al menos 18 ganadas para derivar 18 pedidos const MINIMO_GANADAS = 18; if (ganadas().length < MINIMO_GANADAS) { const relleno = oportunidades .filter((o) => o.etapa === "COTIZACION" && puedeCerrar(o)) .sort((a, b) => b.campana.cierre - a.campana.cierre); for (const o of relleno) { if (ganadas().length >= MINIMO_GANADAS) break; o.etapa = "GANADA"; ponerValor(o); } } // 5d. primera respuesta, cierre y motivo de pérdida const nuevas = oportunidades .filter((o) => o.etapa === "NUEVO") .sort((a, b) => a.creadoEn.getTime() - b.creadoEn.getTime()); // ocho leads nuevos se quedan sin tocar, repartidos por toda la ventana, para // que el tablero tenga trabajo pendiente real y no un rezago simulado const salto = Math.max(Math.floor(nuevas.length / 8), 1); const sinAtender = new Set(); for (let i = 0; i < nuevas.length && sinAtender.size < 8; i += salto) { sinAtender.add(nuevas[i]); } for (const o of oportunidades) { if (!sinAtender.has(o)) { const minutos = porPeso([ { valor: entero(20, 90), peso: 34 }, { valor: entero(91, 360), peso: 30 }, { valor: entero(361, 900), peso: 22 }, { valor: entero(901, 1800), peso: 14 }, ]); o.primeraRespuestaEn = despuesDe(o.creadoEn, minutos); } if (o.etapa === "GANADA" || o.etapa === "PERDIDA") { const diasDisponibles = Math.floor(minutosHastaAhora(o.creadoEn) / 1440); const dias = Math.max(Math.min(entero(2, 26), diasDisponibles), 2); o.cerradoEn = new Date(o.creadoEn.getTime() + dias * DIA_MS); if (o.etapa === "PERDIDA") o.motivoPerdida = elige(MOTIVOS_PERDIDA); if (!o.primeraRespuestaEn) { o.primeraRespuestaEn = despuesDe(o.creadoEn, entero(30, 600)) ?? new Date(o.creadoEn.getTime() + 30 * 60_000); } } } // 6. contactos en base, con fecha anterior a su primer envío const idsContacto: string[] = []; for (const c of contactos) { const suyas = oportunidades .filter((o) => o.contacto === c.indice) .sort((a, b) => a.creadoEn.getTime() - b.creadoEn.getTime()); const primera = suyas[0]; const dueno = usuarios[primera.propietarioIndice]; const creado = await db.contact.create({ data: { nombre: c.nombre, email: c.email, telefono: c.telefono, emailNorm: c.email.toLowerCase(), telefonoNorm: c.telefonoNorm, empresa: c.empresa, puesto: c.puesto, ciudad: c.ciudad, industria: c.industria, notas: probable(0.28) ? "Contacto de la demostración. Confirmar datos antes de cualquier gestión real." : null, propietarioId: dueno.id, primerUtmSource: primera.campana.utmSource, primerUtmMedium: primera.campana.utmMedium, primerUtmCampaign: primera.campana.utmCampaign, primerLandingPath: primera.campana.landingPath, primerReferrer: primera.campana.referrer, creadoEn: new Date(primera.creadoEn.getTime() - entero(10, 240) * 60_000), }, select: { id: true }, }); idsContacto.push(creado.id); } // 7. oportunidades en base, con folio consecutivo por fecha const enOrden = [...oportunidades].sort((a, b) => a.creadoEn.getTime() - b.creadoEn.getTime()); const idsOportunidad = new Map(); for (const [i, o] of enOrden.entries()) { const c = contactos[o.contacto]; const folio = `OPP-${o.creadoEn.getFullYear()}-${String(i + 1).padStart(4, "0")}`; const campana = o.campana; const creada = await db.opportunity.create({ data: { folio, contactoId: idsContacto[o.contacto], propietarioId: usuarios[o.propietarioIndice].id, origen: o.origen, canal: o.canal, etapa: o.etapa, prioridad: o.prioridad, productoSlug: o.productoSlug, productoNombre: o.productoNombre, productoUrl: o.productoUrl, categoriaSlug: o.categoriaSlug, industria: o.industria, clasesFuego: o.clases, superficie: o.superficie, urgencia: o.urgencia, mensaje: o.mensaje, valorEstimado: o.valorEstimado, utmSource: campana.utmSource, utmMedium: campana.utmMedium, utmCampaign: campana.utmCampaign, utmTerm: campana.utmMedium === "cpc" ? elige(["extintor industrial", "nom 002 stps", "espuma contra incendio"] as const) : null, utmContent: campana.utmMedium === "paid_social" ? elige(["carrusel-a", "video-15s", "estatico-b"] as const) : null, gclid: campana.gclid ? `demo-gclid-${1000 + i}` : null, fbclid: campana.fbclid ? `demo-fbclid-${2000 + i}` : null, referrer: campana.referrer, landingPath: campana.landingPath, paginaUrl: `https://fspm-demo.urieljareth.org${o.productoUrl ?? campana.landingPath}`, dispositivo: o.dispositivo, userAgent: o.dispositivo === "movil" ? "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36", creadoEn: o.creadoEn, primeraRespuestaEn: o.primeraRespuestaEn, cerradoEn: o.cerradoEn, motivoPerdida: o.motivoPerdida, }, select: { id: true, folio: true }, }); idsOportunidad.set(o, creada); // 8. bitácora: las dos entradas automáticas que escribe la API const productoTexto = o.productoNombre ?? "sin producto declarado"; const campanaTexto = campana.utmSource || campana.utmCampaign ? `campaña: ${campana.utmCampaign ?? "sin campaña"} (${campana.utmSource ?? "sin fuente"}/${campana.utmMedium ?? "sin medio"})` : "campaña: tráfico directo u orgánico"; await db.activity.create({ data: { oportunidadId: creada.id, tipo: "SISTEMA", texto: `${ o.origen === "WHATSAPP" ? "Contacto registrado desde el widget de WhatsApp" : "Formulario enviado desde el sitio" } · canal: ${o.canal} · producto: ${productoTexto} · ${campanaTexto}${ o.urgencia ? ` · urgencia declarada: ${o.urgencia}` : "" }.`, creadoEn: o.creadoEn, }, }); const dueno = usuarios[o.propietarioIndice]; const motivoRegla = o.industria === "industria-energia" ? 'Regla "Industria y energía"' : o.industria === "logistica-bodegas" ? 'Regla "Logística y bodegas"' : o.industria === "gasolineras-flotas" ? 'Regla "Gasolineras y flotas"' : o.industria === "cocinas-hoteles" ? 'Regla "Cocinas y hoteles"' : o.origen === "WHATSAPP" ? 'Regla "WhatsApp a respuesta rápida"' : 'Regla "Respaldo general"'; await db.activity.create({ data: { oportunidadId: creada.id, usuarioId: dueno.id, tipo: "ASIGNACION", texto: `Asignada a ${dueno.nombre}. Criterio: ${motivoRegla}.`, creadoEn: new Date(o.creadoEn.getTime() + 1000), }, }); // gestiones coherentes con la etapa alcanzada const gestiones = (() => { switch (o.etapa) { case "NUEVO": return o.primeraRespuestaEn ? 1 : 0; case "CONTACTADO": return 1; case "DIAGNOSTICO": return entero(1, 2); case "COTIZACION": return entero(2, 3); case "GANADA": return entero(2, 4); case "PERDIDA": return entero(1, 3); default: return 1; } })(); const guionPorEtapa: readonly { tipo: string; texto: string }[] = [ { tipo: "LLAMADA", texto: `Primer contacto telefónico con ${c.nombre}. Confirmamos el alcance y la superficie a proteger.` }, { tipo: "CORREO", texto: "Enviado el cuestionario de diagnóstico y la referencia de la NOM-002-STPS-2010." }, { tipo: "NOTA", texto: `Riesgo declarado por el cliente: clases ${o.clases ?? "por definir"}. Falta validar en sitio.` }, { tipo: "REUNION", texto: "Visita técnica realizada. Levantamiento de zonas y distancias de recorrido." }, { tipo: "NOTA", texto: "Propuesta técnica enviada con el escenario documentado y el agente recomendado." }, { tipo: "LLAMADA", texto: "Seguimiento a la propuesta. El cliente pidió comparativo de mantenimiento." }, { tipo: "WHATSAPP", texto: "El cliente confirmó por WhatsApp la fecha de la demostración con su personal." }, { tipo: "CORREO", texto: "Enviado el expediente para aseguradora y verificación." }, ]; for (let g = 0; g < gestiones; g += 1) { const guion = guionPorEtapa[g % guionPorEtapa.length]; const base = o.primeraRespuestaEn ?? o.creadoEn; const cuando = despuesDe(base, (g + 1) * entero(6, 40) * 60, 1); if (!cuando) break; await db.activity.create({ data: { oportunidadId: creada.id, usuarioId: dueno.id, tipo: guion.tipo, texto: guion.texto, creadoEn: cuando, }, }); } if (o.etapa === "GANADA" || o.etapa === "PERDIDA") { await db.activity.create({ data: { oportunidadId: creada.id, usuarioId: dueno.id, tipo: "CAMBIO_ETAPA", texto: o.etapa === "GANADA" ? "Etapa: COTIZACION → GANADA. Cliente aprobó la propuesta." : `Etapa: COTIZACION → PERDIDA. Motivo: ${o.motivoPerdida}.`, creadoEn: o.cerradoEn ?? o.creadoEn, }, }); } } // 9. pedidos derivados de oportunidades ganadas const paraPedido = enOrden.filter((o) => o.etapa === "GANADA").slice(0, 18); const estadosPedido = [ "ENTREGADO", "ENTREGADO", "ENTREGADO", "ENVIADO", "ENVIADO", "EN_PROCESO", "EN_PROCESO", "EN_PROCESO", "CONFIRMADO", "CONFIRMADO", "CONFIRMADO", "CONFIRMADO", "BORRADOR", "BORRADOR", "BORRADOR", "ENVIADO", "CANCELADO", "ENTREGADO", ] as const; let pedidosCreados = 0; for (const [i, o] of paraPedido.entries()) { const c = contactos[o.contacto]; const ref = idsOportunidad.get(o); if (!ref) continue; const catalogoDelGiro = PRODUCTOS_POR_INDUSTRIA[c.industria]; const cuantasPartidas = entero(1, 3); const elegidos = new Set(); if (o.productoSlug) elegidos.add(o.productoSlug); while (elegidos.size < cuantasPartidas) elegidos.add(elige(catalogoDelGiro)); const items = [...elegidos].flatMap((slug) => { const p = getProducto(slug); const precio = PRECIOS_DEMO[slug]; if (!p || !precio) return []; return [ { productoSlug: p.slug, nombre: p.nombre, cantidad: precio > 100_000 ? 1 : precio > 20_000 ? entero(1, 3) : entero(2, 12), precioUnitario: precio, }, ]; }); if (items.length === 0) continue; const subtotal = Math.round(items.reduce((s, it) => s + it.cantidad * it.precioUnitario, 0) * 100) / 100; const iva = Math.round(subtotal * IVA * 100) / 100; const total = Math.round((subtotal + iva) * 100) / 100; const creadoEn = o.cerradoEn ?? o.creadoEn; const estado = estadosPedido[i % estadosPedido.length]; await db.order.create({ data: { folio: `PED-${creadoEn.getFullYear()}-${String(i + 1).padStart(4, "0")}`, oportunidadId: ref.id, contactoId: idsContacto[o.contacto], propietarioId: usuarios[o.propietarioIndice].id, estado, moneda: "MXN", subtotal, iva, total, items: JSON.stringify(items), notas: estado === "CANCELADO" ? "Cancelado a solicitud del cliente: reprograma la inversión al siguiente ejercicio." : `Precios de demostración. Entrega coordinada con ${c.puesto.toLowerCase()} en sitio.`, entregaEstimada: new Date(creadoEn.getTime() + entero(7, 45) * DIA_MS), creadoEn, }, }); pedidosCreados += 1; } // 10. contadores alineados con lo sembrado, para que la API siga la serie await db.counter.create({ data: { name: "oportunidad", valor: enOrden.length } }); await db.counter.create({ data: { name: "pedido", valor: pedidosCreados } }); // 11. resumen const [nContactos, nOportunidades, nActividades, nPedidos] = await Promise.all([ db.contact.count(), db.opportunity.count(), db.activity.count(), db.order.count(), ]); const repetidores = ( await db.contact.findMany({ select: { _count: { select: { oportunidades: true } } } }) ).filter((c) => c._count.oportunidades > 1).length; console.info( [ "", "Base de demostración sembrada.", ` Contactos: ${nContactos} (${repetidores} con más de una oportunidad)`, ` Oportunidades: ${nOportunidades} Actividades: ${nActividades} Pedidos: ${nPedidos}`, "", "Acceso al panel en /admin/login — contraseña de todos los perfiles: Fspm2026!", ...usuariosPlan.map((u) => ` ${u.rol.padEnd(8)} ${u.email}`), "", "El acceso rápido de un clic se apaga con DEMO_ACCESO_RAPIDO=false.", "", ].join("\n"), ); } main() .catch((error: unknown) => { console.error("El seed falló:", error); process.exitCode = 1; }) .finally(async () => { await db.$disconnect(); });