Acerca el cálculo a tarifas de mercado sin rehacer el modelo lineal €/m²: - Impermeabilización como partida propia en zonas húmedas (cocina/baño/integral) - Extras fijos que no escalan con m²: boletín (siempre), tuberías (piso anterior a 2000) y cambio de distribución (mover inodoro/ducha/bañera) - Intensidad por tipo en fontanería/electricidad (baseline cocina) para que un integral no escale como un baño - Factor de zona por provincia en tramos (Madrid/BCN 1.40, islas 1.30, capitales 1.20, rural 0.85, resto 1.00) - 2 preguntas nuevas en el formulario del cliente para disparar los extras - Panel de precios: campo de impermeabilización + sección de extras fijos - Seed recalibrado (mano de obra, extras, catálogo suelo/pared) - Migración 0009 (leads.anterior_a_2000, leads.cambio_distribucion, pricing_config.extras) - Tests del motor ampliados (impermeabilización, extras, intensidad por tipo) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
398 lines
16 KiB
TypeScript
398 lines
16 KiB
TypeScript
import {
|
|
pgTable,
|
|
pgEnum,
|
|
uuid,
|
|
text,
|
|
integer,
|
|
boolean,
|
|
numeric,
|
|
timestamp,
|
|
jsonb,
|
|
index,
|
|
doublePrecision,
|
|
uniqueIndex,
|
|
type AnyPgColumn,
|
|
} from 'drizzle-orm/pg-core';
|
|
|
|
// Estado comercial del lead — RF-D-03. Lo que el reformista gestiona a mano.
|
|
export const leadEstado = pgEnum('lead_estado', [
|
|
'nuevo',
|
|
'contactado',
|
|
'visita_agendada',
|
|
'presupuesto_enviado',
|
|
'ganado',
|
|
'perdido',
|
|
]);
|
|
|
|
// Avance técnico en el funnel B2C (docs/funnel.md, superficie C).
|
|
// Cada valor = un momento del pipeline; el "siguiente paso" se deriva de aquí.
|
|
export const pipelineStage = pgEnum('pipeline_stage', [
|
|
'form_completado', // 2. dejó nombre+tel+email+opt-in
|
|
'fotos_subidas', // 3. subió 2-4 fotos
|
|
'prellamada_enviada', // 4. SMS + WhatsApp pre-llamada
|
|
'llamada_completada', // 5. agente IA terminó la cualificación
|
|
'render_generado', // 6. Nano Banana generó el render
|
|
'presupuesto_generado', // 6. motor de presupuesto + PDF listos
|
|
'whatsapp_entregado', // 7. entregado al cliente + lead caliente al panel
|
|
]);
|
|
|
|
export const tipoReforma = pgEnum('tipo_reforma', [
|
|
'cocina',
|
|
'bano',
|
|
'salon',
|
|
'comedor',
|
|
'integral',
|
|
'otro',
|
|
]);
|
|
|
|
export const calidad = pgEnum('calidad', ['basica', 'media', 'premium']);
|
|
|
|
// Momento de una foto del lead: el estado antes de la reforma o el render del después.
|
|
export const fotoMomento = pgEnum('foto_momento', ['antes', 'despues']);
|
|
|
|
export const urgencia = pgEnum('urgencia', ['alta', 'media', 'baja']);
|
|
|
|
export const categoriaMaterial = pgEnum('categoria_material', [
|
|
'suelo',
|
|
'pared',
|
|
'pintura',
|
|
'mobiliario',
|
|
]);
|
|
|
|
export const unidadMedida = pgEnum('unidad_medida', ['m2', 'ml', 'ud']);
|
|
|
|
export const userRole = pgEnum('user_role', ['reformista', 'admin']);
|
|
export const userStatus = pgEnum('user_status', ['activo', 'deshabilitado']);
|
|
export const subscriptionStatus = pgEnum('subscription_status', [
|
|
'trial',
|
|
'activo',
|
|
'cancelado',
|
|
'vencido',
|
|
]);
|
|
|
|
// Cómo entrega el reformista el presupuesto al cliente final.
|
|
// 'automatico' = el funnel lo envía solo; 'revision' = se para para que el reformista lo revise/edite antes de enviar.
|
|
export const envioPresupuestoMode = pgEnum('envio_presupuesto_mode', ['automatico', 'revision']);
|
|
|
|
// Estado de moderación de un testimonio. El reformista aprueba antes de publicar.
|
|
// 'pendiente' = recién enviado por el cliente; 'publicado' = visible en la landing; 'oculto' = retirado.
|
|
export const testimonioEstado = pgEnum('testimonio_estado', ['pendiente', 'publicado', 'oculto']);
|
|
|
|
// Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded.
|
|
// Multi-tenant real es F1.5; la tabla ya queda lista para ello.
|
|
export const tenants = pgTable('tenants', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
slug: text('slug').notNull().unique(),
|
|
nombreEmpresa: text('nombre_empresa').notNull(),
|
|
logoUrl: text('logo_url'), // data URI base64 del logo (no hay storage externo aún)
|
|
provincia: text('provincia'),
|
|
whatsappBusiness: text('whatsapp_business'),
|
|
// SEO personalizable de la landing del reformista (RF-A / funnel público).
|
|
seoTitle: text('seo_title'),
|
|
seoDescription: text('seo_description'),
|
|
// Bloque "Quiénes somos" opcional en el funnel del reformista.
|
|
aboutEnabled: boolean('about_enabled').notNull().default(false),
|
|
aboutFotoUrl: text('about_foto_url'), // data URI base64 de la foto del reformista
|
|
aboutTexto: text('about_texto'),
|
|
aniosExperiencia: integer('anios_experiencia'),
|
|
// Tema visual de la landing del reformista (personalización del funnel público).
|
|
// themePreset = id de THEME_PRESETS; themeColor = override hex opcional del color primario.
|
|
themePreset: text('theme_preset').notNull().default('pizarra'),
|
|
themeColor: text('theme_color'),
|
|
// Datos de empresa para la cabecera del presupuesto (RF-D-07).
|
|
cif: text('cif'),
|
|
direccion: text('direccion'),
|
|
telefono: text('telefono'),
|
|
email: text('email'),
|
|
web: text('web'),
|
|
planId: uuid('plan_id').references((): AnyPgColumn => plans.id),
|
|
subscriptionStatus: subscriptionStatus('subscription_status').notNull().default('trial'),
|
|
envioPresupuesto: envioPresupuestoMode('envio_presupuesto').notNull().default('automatico'),
|
|
trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }),
|
|
stripeCustomerId: text('stripe_customer_id'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export const plans = pgTable('plans', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
slug: text('slug').notNull().unique(),
|
|
nombre: text('nombre').notNull(),
|
|
precioMensual: integer('precio_mensual').notNull(), // céntimos
|
|
leadsIncluidos: integer('leads_incluidos').notNull(),
|
|
features: jsonb('features').$type<string[]>().notNull().default([]),
|
|
activo: boolean('activo').notNull().default(true),
|
|
});
|
|
|
|
export const users = pgTable(
|
|
'users',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
email: text('email').notNull().unique(),
|
|
passwordHash: text('password_hash').notNull(),
|
|
nombre: text('nombre'),
|
|
role: userRole('role').notNull().default('reformista'),
|
|
tenantId: uuid('tenant_id').references(() => tenants.id, { onDelete: 'cascade' }),
|
|
status: userStatus('status').notNull().default('activo'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('users_tenant_idx').on(table.tenantId)]
|
|
);
|
|
|
|
export const sessions = pgTable(
|
|
'sessions',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
userId: uuid('user_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
tokenHash: text('token_hash').notNull().unique(),
|
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('sessions_user_idx').on(table.userId)]
|
|
);
|
|
|
|
export const leads = pgTable(
|
|
'leads',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
tenantId: uuid('tenant_id')
|
|
.notNull()
|
|
.references(() => tenants.id, { onDelete: 'cascade' }),
|
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
|
|
// Datos del cliente final (paso 2 del funnel)
|
|
nombre: text('nombre').notNull(),
|
|
telefono: text('telefono').notNull(),
|
|
email: text('email').notNull(),
|
|
provincia: text('provincia'),
|
|
tipoReforma: tipoReforma('tipo_reforma'),
|
|
|
|
// Consentimientos LSSI-CE + RGPD (RF-LEG-01)
|
|
consentPrivacidad: boolean('consent_privacidad').notNull().default(false),
|
|
consentContratacion: boolean('consent_contratacion').notNull().default(false),
|
|
|
|
// Posición en el funnel y estado comercial
|
|
pipelineStage: pipelineStage('pipeline_stage').notNull().default('form_completado'),
|
|
estado: leadEstado('estado').notNull().default('nuevo'),
|
|
|
|
// Presupuesto orientativo en céntimos de euro (evita floats)
|
|
presupuestoEstimado: integer('presupuesto_estimado'),
|
|
|
|
// Artefactos generados por el pipeline (RF-D-02)
|
|
transcripcion: text('transcripcion'),
|
|
entidades: jsonb('entidades'),
|
|
renderUrl: text('render_url'),
|
|
pdfUrl: text('pdf_url'),
|
|
audioUrl: text('audio_url'),
|
|
|
|
notas: text('notas'),
|
|
|
|
// Cuándo el reformista pidió la opinión al cliente (RF: recogida de testimonios).
|
|
testimonioSolicitadoAt: timestamp('testimonio_solicitado_at', { withTimezone: true }),
|
|
|
|
// Inputs del motor de presupuesto (capturados de menos a más en el funnel)
|
|
m2Suelo: doublePrecision('m2_suelo'),
|
|
alturaTecho: doublePrecision('altura_techo'),
|
|
calidadGlobal: calidad('calidad_global'),
|
|
estructural: boolean('estructural').notNull().default(false),
|
|
// Inputs de los extras fijos del presupuesto (no escalan con m²).
|
|
anteriorA2000: boolean('anterior_a_2000').notNull().default(false),
|
|
cambioDistribucion: boolean('cambio_distribucion').notNull().default(false),
|
|
materialSelections: jsonb('material_selections')
|
|
.$type<Record<string, string>>()
|
|
.notNull()
|
|
.default({}),
|
|
desgloseSnapshot: jsonb('desglose_snapshot'),
|
|
|
|
// Preferencias del cliente capturadas en la llamada (agente de voz)
|
|
urgencia: urgencia('urgencia'),
|
|
presupuestoTarget: integer('presupuesto_target'), // céntimos
|
|
tasteText: text('taste_text'),
|
|
preferencesSnapshot: jsonb('preferences_snapshot'),
|
|
},
|
|
(table) => [
|
|
index('leads_tenant_created_idx').on(table.tenantId, table.createdAt),
|
|
index('leads_estado_idx').on(table.estado),
|
|
]
|
|
);
|
|
|
|
// Fotos del lead, etiquetadas por zona y momento. Las "antes" las sube el cliente (funnel o EP);
|
|
// las "despues" (renders) las devuelve el flujo de generación externo por el mismo EP de ingesta.
|
|
// zona es nullable por compatibilidad con filas antiguas (fallback al tipoReforma del lead).
|
|
export const leadFotos = pgTable('lead_fotos', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
leadId: uuid('lead_id')
|
|
.notNull()
|
|
.references(() => leads.id, { onDelete: 'cascade' }),
|
|
url: text('url').notNull(),
|
|
momento: fotoMomento('momento').notNull().default('antes'),
|
|
zona: tipoReforma('zona'),
|
|
orden: integer('orden').notNull().default(0),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// Datos de texto que enriquecen el perfil del lead por zona (ej. "Baño: suelo premium").
|
|
// Append-only: cada llamada al EP de ingesta puede añadir notas que el agente externo homologará.
|
|
export const leadNotas = pgTable('lead_notas', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
leadId: uuid('lead_id')
|
|
.notNull()
|
|
.references(() => leads.id, { onDelete: 'cascade' }),
|
|
zona: tipoReforma('zona'),
|
|
texto: text('texto').notNull(),
|
|
origen: text('origen').notNull().default('ep'), // ep | funnel | panel
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// Opiniones del cliente final, recogidas en el funnel de review (/opinion/[id]).
|
|
// El reformista las solicita desde el panel y aprueba antes de que salgan en su landing.
|
|
export const testimonios = pgTable(
|
|
'testimonios',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
tenantId: uuid('tenant_id')
|
|
.notNull()
|
|
.references(() => tenants.id, { onDelete: 'cascade' }),
|
|
leadId: uuid('lead_id').references(() => leads.id, { onDelete: 'set null' }),
|
|
nombre: text('nombre').notNull(),
|
|
contexto: text('contexto'), // p.ej. "Reforma de cocina · Madrid"
|
|
rating: integer('rating').notNull(), // 1-5 estrellas
|
|
texto: text('texto').notNull(),
|
|
estado: testimonioEstado('estado').notNull().default('pendiente'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('testimonios_tenant_estado_idx').on(table.tenantId, table.estado),
|
|
index('testimonios_lead_idx').on(table.leadId),
|
|
]
|
|
);
|
|
|
|
// Fotos adjuntas a un testimonio (el cliente sube fotos del resultado).
|
|
export const testimonioFotos = pgTable('testimonio_fotos', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
testimonioId: uuid('testimonio_id')
|
|
.notNull()
|
|
.references(() => testimonios.id, { onDelete: 'cascade' }),
|
|
url: text('url').notNull(),
|
|
orden: integer('orden').notNull().default(0),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// Galería de trabajos del reformista (fotos de reformas hechas), visible en su landing.
|
|
export const galeriaFotos = pgTable(
|
|
'galeria_fotos',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
tenantId: uuid('tenant_id')
|
|
.notNull()
|
|
.references(() => tenants.id, { onDelete: 'cascade' }),
|
|
url: text('url').notNull(), // data URI base64 (no hay storage externo aún)
|
|
titulo: text('titulo'),
|
|
orden: integer('orden').notNull().default(0),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('galeria_tenant_idx').on(table.tenantId)]
|
|
);
|
|
|
|
// Histórico de cambios de estado comercial (RF-D-03: persistir y reflejar)
|
|
export const leadEstadoHistory = pgTable('lead_estado_history', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
leadId: uuid('lead_id')
|
|
.notNull()
|
|
.references(() => leads.id, { onDelete: 'cascade' }),
|
|
estado: leadEstado('estado').notNull(),
|
|
changedAt: timestamp('changed_at', { withTimezone: true }).notNull().defaultNow(),
|
|
changedBy: text('changed_by'),
|
|
});
|
|
|
|
// Timeline del funnel: una fila por cada paso alcanzado.
|
|
// Permite ver dónde está el lead y cuál es el siguiente paso.
|
|
export const leadPipelineEventos = pgTable('lead_pipeline_eventos', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
leadId: uuid('lead_id')
|
|
.notNull()
|
|
.references(() => leads.id, { onDelete: 'cascade' }),
|
|
stage: pipelineStage('stage').notNull(),
|
|
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
|
|
metadata: jsonb('metadata'),
|
|
});
|
|
|
|
// Bucle de precisión (RF-D-04): precio final firmado vs estimado.
|
|
export const precisionHistory = pgTable('precision_history', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
leadId: uuid('lead_id')
|
|
.notNull()
|
|
.references(() => leads.id, { onDelete: 'cascade' }),
|
|
estimated: integer('estimated').notNull(), // céntimos
|
|
final: integer('final').notNull(), // céntimos
|
|
deltaPct: numeric('delta_pct', { precision: 6, scale: 2 }).notNull(),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// Configuración de precios del reformista (1 fila por tenant). RF-D-07.
|
|
export const pricingConfig = pgTable('pricing_config', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
tenantId: uuid('tenant_id')
|
|
.notNull()
|
|
.references(() => tenants.id, { onDelete: 'cascade' })
|
|
.unique(),
|
|
alturaTechoDefault: doublePrecision('altura_techo_default').notNull().default(2.5),
|
|
factorZona: jsonb('factor_zona').$type<Record<string, number>>().notNull().default({}),
|
|
manoObra: jsonb('mano_obra').$type<Record<string, number>>().notNull().default({}),
|
|
// Extras fijos en céntimos: { tuberias, boletin, distribucion }.
|
|
extras: jsonb('extras')
|
|
.$type<{ tuberias: number; boletin: number; distribucion: number }>()
|
|
.notNull()
|
|
.default({ tuberias: 0, boletin: 0, distribucion: 0 }),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// Catálogo de materiales del reformista. Importable por CSV.
|
|
export const catalogItems = pgTable(
|
|
'catalog_items',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
tenantId: uuid('tenant_id')
|
|
.notNull()
|
|
.references(() => tenants.id, { onDelete: 'cascade' }),
|
|
categoria: categoriaMaterial('categoria').notNull(),
|
|
nombre: text('nombre').notNull(),
|
|
calidad: calidad('calidad').notNull(),
|
|
precioUnit: integer('precio_unit').notNull(), // céntimos por unidad
|
|
unidad: unidadMedida('unidad').notNull(),
|
|
descriptorRender: text('descriptor_render').notNull().default(''),
|
|
esDefault: boolean('es_default').notNull().default(false),
|
|
sku: text('sku').notNull(),
|
|
},
|
|
(table) => [
|
|
index('catalog_tenant_idx').on(table.tenantId),
|
|
uniqueIndex('catalog_tenant_sku_idx').on(table.tenantId, table.sku),
|
|
]
|
|
);
|
|
|
|
export type Tenant = typeof tenants.$inferSelect;
|
|
export type Lead = typeof leads.$inferSelect;
|
|
export type NewLead = typeof leads.$inferInsert;
|
|
export type LeadFoto = typeof leadFotos.$inferSelect;
|
|
export type NewLeadFoto = typeof leadFotos.$inferInsert;
|
|
export type LeadNota = typeof leadNotas.$inferSelect;
|
|
export type NewLeadNota = typeof leadNotas.$inferInsert;
|
|
export type Testimonio = typeof testimonios.$inferSelect;
|
|
export type NewTestimonio = typeof testimonios.$inferInsert;
|
|
export type TestimonioFoto = typeof testimonioFotos.$inferSelect;
|
|
export type GaleriaFoto = typeof galeriaFotos.$inferSelect;
|
|
export type LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect;
|
|
export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect;
|
|
export type PrecisionHistory = typeof precisionHistory.$inferSelect;
|
|
export type PricingConfigRow = typeof pricingConfig.$inferSelect;
|
|
export type CatalogItemRow = typeof catalogItems.$inferSelect;
|
|
export type NewCatalogItem = typeof catalogItems.$inferInsert;
|
|
export type Plan = typeof plans.$inferSelect;
|
|
export type User = typeof users.$inferSelect;
|
|
export type NewUser = typeof users.$inferInsert;
|
|
export type Session = typeof sessions.$inferSelect;
|