Files
fspm-web/prisma/schema.prisma
T
Uriel JarethandClaude Opus 5 01b817d098 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]>
2026-08-28 00:07:20 -06:00

221 lines
6.1 KiB
Plaintext

// 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)
}