Sitio público orientado a venta consultiva e implementación de las recomendaciones de la auditoría de marca y mercado: - Inicio con tres rutas (industria, riesgo, cumplimiento) y clasificador de riesgo en el héroe, contra el hallazgo de que el visitante tenía que inferir qué solución le correspondía. - Cinco páginas por segmento prioritario y catálogo comparable por clase de fuego, capacidad y agente. - Los claims sin evidencia por modelo quedan aislados y se presentan como compromiso de documentación, nunca como característica del producto. - SEO técnico completo: metadatos por página, sitemap derivado de los datos, JSON-LD, iconos y tarjeta social generadas. Micro CRM: - Captura por formulario embebido y por widget de WhatsApp que registra antes de abrir la conversación. - Atribución de primer toque que sobrevive a la navegación. - Deduplicación de contacto; un mismo contacto acumula oportunidades. - Motor de asignación por reglas con reparto por turno de respaldo. - Panel con dos roles de alcance distinto, tablero analítico, bitácora, pedidos y reglas. No enlazado desde el sitio público y con noindex. Empaquetado en Docker multietapa con la base de demostración horneada en la imagen y el volumen de datos separado, para que los leads capturados sobrevivan a los redespliegues. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
1261 lines
43 KiB
TypeScript
1261 lines
43 KiB
TypeScript
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<T>(lista: readonly T[]): T {
|
|
return lista[Math.floor(aleatorio() * lista.length)];
|
|
}
|
|
|
|
function probable(p: number): boolean {
|
|
return aleatorio() < p;
|
|
}
|
|
|
|
function porPeso<T>(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<string, readonly string[]> = {
|
|
"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<string, readonly string[]> = {
|
|
"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<string, readonly string[]> = {
|
|
"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<string, readonly string[]> = {
|
|
"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<string, readonly string[]> = {
|
|
"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<string, number> = {
|
|
"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<string, [number, number]> = {
|
|
"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<void> {
|
|
// 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: "[email protected]",
|
|
rol: "ADMIN",
|
|
puesto: "Dirección comercial",
|
|
telefono: "+52 55 4034 8300",
|
|
},
|
|
{
|
|
nombre: "Mariana Escobedo",
|
|
email: "[email protected]",
|
|
rol: "VENDEDOR",
|
|
puesto: "Especialista en industria y energía",
|
|
telefono: "+52 55 4034 8301",
|
|
},
|
|
{
|
|
nombre: "Luis Ángel Terán",
|
|
email: "[email protected]",
|
|
rol: "VENDEDOR",
|
|
puesto: "Especialista en logística y bodegas",
|
|
telefono: "+52 55 4034 8302",
|
|
},
|
|
{
|
|
nombre: "Paulina Cárdenas",
|
|
email: "[email protected]",
|
|
rol: "VENDEDOR",
|
|
puesto: "Especialista en cocinas, hoteles y PyMEs",
|
|
telefono: "+52 55 4034 8303",
|
|
},
|
|
{
|
|
nombre: "Emilio Nájera",
|
|
email: "[email protected]",
|
|
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<string, ClaseFuego[]>(
|
|
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<number>(10).fill(3),
|
|
...Array<number>(15).fill(2),
|
|
...Array<number>(35).fill(1),
|
|
];
|
|
|
|
const usadosEmail = new Set<string>();
|
|
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<T>(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<PlanOportunidad>();
|
|
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<PlanOportunidad, { id: string; folio: string }>();
|
|
|
|
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<string>();
|
|
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();
|
|
});
|