feat: sitio comercial FSPM con micro CRM interno
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
122e06a4bb
commit
01b817d098
@@ -0,0 +1,220 @@
|
||||
// FSPM — micro CRM
|
||||
//
|
||||
// SQLite no soporta enums nativos en Prisma: los campos de estado son String
|
||||
// y su dominio vive en src/lib/crm/constantes.ts, que es la fuente de verdad.
|
||||
// No inventes valores fuera de esas constantes.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
/// Usuario del panel. rol: "ADMIN" | "VENDEDOR"
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
nombre String
|
||||
email String @unique
|
||||
passwordHash String
|
||||
rol String @default("VENDEDOR")
|
||||
puesto String?
|
||||
telefono String?
|
||||
activo Boolean @default(true)
|
||||
creadoEn DateTime @default(now())
|
||||
|
||||
contactos Contact[]
|
||||
oportunidades Opportunity[]
|
||||
actividades Activity[]
|
||||
pedidos Order[]
|
||||
reglas AssignmentRule[]
|
||||
|
||||
@@index([rol, activo])
|
||||
}
|
||||
|
||||
/// Persona. Un mismo contacto puede generar muchas oportunidades.
|
||||
model Contact {
|
||||
id String @id @default(cuid())
|
||||
nombre String
|
||||
email String?
|
||||
telefono String?
|
||||
/// email en minúsculas y sin espacios — clave de deduplicación
|
||||
emailNorm String?
|
||||
/// solo dígitos, últimos 10 — clave de deduplicación
|
||||
telefonoNorm String?
|
||||
empresa String?
|
||||
puesto String?
|
||||
ciudad String?
|
||||
/// slug de industria, ver src/data/industrias.ts
|
||||
industria String?
|
||||
notas String?
|
||||
|
||||
propietarioId String?
|
||||
propietario User? @relation(fields: [propietarioId], references: [id], onDelete: SetNull)
|
||||
|
||||
// atribución de primer toque: nunca se sobreescribe
|
||||
primerUtmSource String?
|
||||
primerUtmMedium String?
|
||||
primerUtmCampaign String?
|
||||
primerLandingPath String?
|
||||
primerReferrer String?
|
||||
|
||||
creadoEn DateTime @default(now())
|
||||
actualizadoEn DateTime @updatedAt
|
||||
|
||||
oportunidades Opportunity[]
|
||||
pedidos Order[]
|
||||
|
||||
@@index([emailNorm])
|
||||
@@index([telefonoNorm])
|
||||
@@index([propietarioId])
|
||||
@@index([creadoEn])
|
||||
}
|
||||
|
||||
/// Un envío de formulario o de widget de WhatsApp convertido en oportunidad.
|
||||
model Opportunity {
|
||||
id String @id @default(cuid())
|
||||
/// OPP-2026-0001
|
||||
folio String @unique
|
||||
|
||||
contactoId String
|
||||
contacto Contact @relation(fields: [contactoId], references: [id], onDelete: Cascade)
|
||||
|
||||
propietarioId String?
|
||||
propietario User? @relation(fields: [propietarioId], references: [id], onDelete: SetNull)
|
||||
|
||||
/// "FORMULARIO" | "WHATSAPP"
|
||||
origen String
|
||||
/// dónde se envió: "home" | "catalogo" | "producto" | "solucion" | "contacto" | "widget"
|
||||
canal String?
|
||||
/// "NUEVO" | "CONTACTADO" | "DIAGNOSTICO" | "COTIZACION" | "GANADA" | "PERDIDA"
|
||||
etapa String @default("NUEVO")
|
||||
/// "ALTA" | "MEDIA" | "BAJA"
|
||||
prioridad String @default("MEDIA")
|
||||
|
||||
// interés declarado
|
||||
productoSlug String?
|
||||
productoNombre String?
|
||||
/// URL del producto de interés, tal como la vio el usuario
|
||||
productoUrl String?
|
||||
categoriaSlug String?
|
||||
industria String?
|
||||
/// clases de fuego separadas por coma, ej. "B,K"
|
||||
clasesFuego String?
|
||||
superficie String?
|
||||
urgencia String?
|
||||
mensaje String?
|
||||
valorEstimado Float?
|
||||
|
||||
// atribución de campaña
|
||||
utmSource String?
|
||||
utmMedium String?
|
||||
utmCampaign String?
|
||||
utmTerm String?
|
||||
utmContent String?
|
||||
gclid String?
|
||||
fbclid String?
|
||||
referrer String?
|
||||
landingPath String?
|
||||
paginaUrl String?
|
||||
dispositivo String?
|
||||
userAgent String?
|
||||
|
||||
creadoEn DateTime @default(now())
|
||||
actualizadoEn DateTime @updatedAt
|
||||
primeraRespuestaEn DateTime?
|
||||
cerradoEn DateTime?
|
||||
motivoPerdida String?
|
||||
|
||||
actividades Activity[]
|
||||
pedidos Order[]
|
||||
|
||||
@@index([etapa])
|
||||
@@index([propietarioId])
|
||||
@@index([contactoId])
|
||||
@@index([creadoEn])
|
||||
@@index([utmCampaign])
|
||||
@@index([origen])
|
||||
}
|
||||
|
||||
/// Bitácora de la oportunidad.
|
||||
model Activity {
|
||||
id String @id @default(cuid())
|
||||
|
||||
oportunidadId String
|
||||
oportunidad Opportunity @relation(fields: [oportunidadId], references: [id], onDelete: Cascade)
|
||||
|
||||
usuarioId String?
|
||||
usuario User? @relation(fields: [usuarioId], references: [id], onDelete: SetNull)
|
||||
|
||||
/// "NOTA" | "LLAMADA" | "CORREO" | "WHATSAPP" | "REUNION" | "CAMBIO_ETAPA" | "ASIGNACION" | "SISTEMA"
|
||||
tipo String
|
||||
texto String
|
||||
creadoEn DateTime @default(now())
|
||||
|
||||
@@index([oportunidadId, creadoEn])
|
||||
}
|
||||
|
||||
/// Pedido derivado de una oportunidad ganada.
|
||||
model Order {
|
||||
id String @id @default(cuid())
|
||||
/// PED-2026-0001
|
||||
folio String @unique
|
||||
|
||||
oportunidadId String?
|
||||
oportunidad Opportunity? @relation(fields: [oportunidadId], references: [id], onDelete: SetNull)
|
||||
|
||||
contactoId String
|
||||
contacto Contact @relation(fields: [contactoId], references: [id], onDelete: Cascade)
|
||||
|
||||
propietarioId String?
|
||||
propietario User? @relation(fields: [propietarioId], references: [id], onDelete: SetNull)
|
||||
|
||||
/// "BORRADOR" | "CONFIRMADO" | "EN_PROCESO" | "ENVIADO" | "ENTREGADO" | "CANCELADO"
|
||||
estado String @default("BORRADOR")
|
||||
moneda String @default("MXN")
|
||||
subtotal Float @default(0)
|
||||
iva Float @default(0)
|
||||
total Float @default(0)
|
||||
/// JSON: [{ productoSlug, nombre, cantidad, precioUnitario }]
|
||||
items String @default("[]")
|
||||
notas String?
|
||||
|
||||
entregaEstimada DateTime?
|
||||
creadoEn DateTime @default(now())
|
||||
actualizadoEn DateTime @updatedAt
|
||||
|
||||
@@index([estado])
|
||||
@@index([propietarioId])
|
||||
@@index([creadoEn])
|
||||
}
|
||||
|
||||
/// Regla de asignación de oportunidades. Se evalúan por prioridad ascendente,
|
||||
/// gana la primera que coincide; si ninguna coincide, se reparte por turno
|
||||
/// entre los vendedores activos.
|
||||
model AssignmentRule {
|
||||
id String @id @default(cuid())
|
||||
nombre String
|
||||
/// menor número = se evalúa primero
|
||||
prioridad Int @default(100)
|
||||
/// "INDUSTRIA" | "CATEGORIA" | "ORIGEN" | "CLASE" | "CUALQUIERA"
|
||||
tipo String
|
||||
/// valor a comparar; null para "CUALQUIERA"
|
||||
valor String?
|
||||
|
||||
usuarioId String
|
||||
usuario User @relation(fields: [usuarioId], references: [id], onDelete: Cascade)
|
||||
|
||||
activa Boolean @default(true)
|
||||
creadoEn DateTime @default(now())
|
||||
|
||||
@@index([activa, prioridad])
|
||||
}
|
||||
|
||||
/// Contadores para folios legibles. name: "oportunidad" | "pedido"
|
||||
model Counter {
|
||||
name String @id
|
||||
valor Int @default(0)
|
||||
}
|
||||
Reference in New Issue
Block a user