Compare commits
7 Commits
8ef99b56fe
...
cd38fe6233
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd38fe6233 | ||
|
|
4d464d40ef | ||
|
|
2bc34d4017 | ||
|
|
2e3cd78216 | ||
|
|
daa58c39a1 | ||
|
|
aee82267d0 | ||
|
|
f082351b43 |
@@ -582,47 +582,54 @@ Documento que se adjunta en la entrega por WhatsApp y se descarga desde el panel
|
|||||||
Emails que se envían al cliente desde la marca del reformista. Tono cercano, honesto y orientativo,
|
Emails que se envían al cliente desde la marca del reformista. Tono cercano, honesto y orientativo,
|
||||||
igual que el resto del funnel. `[Reformista]` = nombre de la empresa; se usa como remitente.
|
igual que el resto del funnel. `[Reformista]` = nombre de la empresa; se usa como remitente.
|
||||||
|
|
||||||
|
Diseño: HTML transaccional mobile-first, una columna (máx. 600px), dark mode, botón "bulletproof"
|
||||||
|
(tabla), tipografías de sistema, color de acento = color de marca del reformista. Cada email lleva
|
||||||
|
asunto (≤50 car.), preheader (texto de previsualización) y versión en texto plano.
|
||||||
|
|
||||||
### Email de entrega del presupuesto (PDF adjunto)
|
### Email de entrega del presupuesto (PDF adjunto)
|
||||||
|
|
||||||
Se envía siempre al terminar el perfil, con el PDF del presupuesto adjunto.
|
Se envía siempre al terminar el perfil, con el PDF del presupuesto adjunto. Tono: la entrega es el
|
||||||
|
protagonista, cálido pero claro.
|
||||||
|
|
||||||
- **Asunto:** *Tu presupuesto de reforma con [Reformista] ya está listo*
|
- **Asunto (elegido):** *Aquí está tu presupuesto de reforma*
|
||||||
|
- **Asunto (alt. A):** *Tu reforma, en números y en imágenes*
|
||||||
|
- **Asunto (alt. B):** *[Reformista]: tu presupuesto ya está listo*
|
||||||
|
- **Preheader:** *Dentro: el render de cómo quedaría y el desglose por partidas (PDF adjunto).*
|
||||||
|
- **Headline:** *Aquí está tu presupuesto, [Nombre]*
|
||||||
- **Cuerpo:**
|
- **Cuerpo:**
|
||||||
|
|
||||||
> Hola [Nombre],
|
> Hemos preparado el **presupuesto orientativo** de tu reforma. En el PDF adjunto tienes el render de
|
||||||
|
> cómo quedaría tu espacio y el desglose por partidas.
|
||||||
>
|
>
|
||||||
> Aquí tienes tu **presupuesto orientativo de reforma**, preparado por [Reformista]. Lo encontrarás
|
> *Es una estimación.* El precio definitivo lo confirma **[Reformista]** en una **visita gratuita** en
|
||||||
> adjunto en PDF, con el render de cómo quedaría tu espacio y el desglose por partidas.
|
> tu casa, donde lo mide todo al detalle y lo ajusta. Sin compromiso.
|
||||||
>
|
>
|
||||||
> ⚠️ Es una **estimación**. El precio definitivo lo confirma [Reformista] en una visita gratuita en
|
> ¿Dudas con el render o con alguna partida? Respóndenos y te lo explicamos.
|
||||||
> tu casa, donde mide todo con detalle y lo ajusta.
|
|
||||||
>
|
- **CTA (si hay teléfono/email del reformista):** *Agendar mi visita gratuita*
|
||||||
> Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin
|
- **Footer:** *Presupuesto orientativo. El precio final puede variar según la visita técnica. · [Reformista]*
|
||||||
> compromiso.
|
|
||||||
>
|
|
||||||
> —
|
|
||||||
> [Reformista]
|
|
||||||
|
|
||||||
### Email con enlace al formulario (subir imágenes)
|
### Email con enlace al formulario (subir imágenes)
|
||||||
|
|
||||||
Se envía cuando el cliente eligió continuar por llamada y necesita un sitio donde subir las fotos
|
Se envía cuando el cliente eligió continuar por llamada y necesita un sitio donde subir las fotos
|
||||||
del espacio. `[url]` apunta a su formulario personal del funnel.
|
del espacio. `[url]` apunta a su formulario personal del funnel. Tono: una sola acción clara.
|
||||||
|
|
||||||
- **Asunto:** *Sube las fotos de tu reforma para [Reformista]*
|
- **Asunto (elegido):** *Sube las fotos de tu reforma*
|
||||||
|
- **Asunto (alt. A):** *Un paso más para tu presupuesto*
|
||||||
|
- **Asunto (alt. B):** *[Reformista] necesita ver tu espacio*
|
||||||
|
- **Preheader:** *Un minuto para subir las fotos de cada zona y seguimos con tu presupuesto.*
|
||||||
|
- **Headline:** *Enséñanos tu espacio, [Nombre]*
|
||||||
- **Cuerpo:**
|
- **Cuerpo:**
|
||||||
|
|
||||||
> Hola [Nombre],
|
> Para preparar tu render y tu presupuesto, **[Reformista]** necesita ver cómo está ahora tu espacio.
|
||||||
>
|
>
|
||||||
> Para preparar tu render y tu presupuesto, [Reformista] necesita ver el espacio. Sube unas fotos
|
> Sube unas fotos de cada zona desde el botón de abajo. Tardas un minuto y puedes añadir las que
|
||||||
> de cada zona desde este enlace, cuando te venga bien:
|
> quieras.
|
||||||
>
|
>
|
||||||
> 👉 [Subir mis fotos]([url])
|
> En cuanto las tengamos, seguimos con tu presupuesto.
|
||||||
>
|
|
||||||
> Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu
|
- **CTA:** *Subir mis fotos* → `[url]`
|
||||||
> presupuesto.
|
- **Footer:** *[Reformista]*
|
||||||
>
|
|
||||||
> —
|
|
||||||
> [Reformista]
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,165 @@
|
|||||||
# Reformix Luisa Bot 🤖
|
# Reformix Luisa Bot 🤖
|
||||||
|
|
||||||
Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de forma conversacional, recoge 5 datos clave y cierra el flujo según el flag viable/no_viable.
|
Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de forma conversacional siguiendo una máquina de estados de 7 pasos, recoge 5 datos clave (espacio, metros, estilo, urgencia, presupuesto) y cierra el flujo según el flag viable/no_viable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **NestJS** — framework principal
|
| Capa | Tecnología |
|
||||||
- **Baileys** — conexión con WhatsApp (sin API oficial)
|
|------|-----------|
|
||||||
- **PostgreSQL** — base de datos via TypeORM
|
| **Framework** | NestJS 10 |
|
||||||
- **Claude 4.5** via **OpenRouter** — LLM con soporte de texto, audio e imagen
|
| **WhatsApp** | Baileys 7 (`@whiskeysockets/baileys`) + `baileys-antiban` |
|
||||||
|
| **Base de datos** | PostgreSQL via TypeORM con `synchronize: true` (dev) / migrations (prod) |
|
||||||
|
| **LLM** | Claude 4.5 Sonnet/Haiku + Gemini 2.5 Flash via **OpenRouter** |
|
||||||
|
| **Logging** | Pino |
|
||||||
|
| **QR** | `qrcode-terminal` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Estructura del proyecto
|
## Estructura del proyecto
|
||||||
|
|
||||||
```
|
```
|
||||||
/src
|
/
|
||||||
/whatsapp ← Módulo Baileys: conexión, QR, recepción y envío
|
├── auth_info_baileys/ ← Estado de sesión de WhatsApp (se genera automáticamente)
|
||||||
/leads ← Módulo de leads: CRUD y lógica de estados
|
├── dist/ ← Compilación
|
||||||
/conversacion ← Módulo de historial de mensajes por lead
|
├── node_modules/
|
||||||
/scheduler ← Cron cada 5 min: dispara apertura a leads nuevos
|
├── prompts/ ← Prompts del sistema para Claude
|
||||||
/claude ← Construye el contexto y llama a Claude 4.5
|
│ ├── luisa_core.md ← Identidad, personalidad y máquina de estados
|
||||||
/media ← Procesa audio e imagen antes de pasar a Claude
|
│ ├── luisa_flujo.md ← Flujo de cualificación paso a paso
|
||||||
|
│ └── luisa_casos.md ← Casos edge y ejemplos
|
||||||
/prompts
|
├── src/
|
||||||
luisa_core.md ← Identidad y personalidad de Luisa ← RELLENAR
|
│ ├── main.ts ← Punto de entrada
|
||||||
luisa_flujo.md ← Flujo de cualificación paso a paso ← RELLENAR
|
│ ├── app.module.ts ← Módulo raíz con TypeORM y Schedule
|
||||||
luisa_casos.md ← Casos edge y ejemplos ← RELLENAR
|
│ ├── whatsapp/
|
||||||
|
│ │ ├── whatsapp.module.ts
|
||||||
|
│ │ ├── whatsapp.service.ts ← Conexión Baileys, recepción/envío
|
||||||
|
│ │ └── whatsapp-debounce.service.ts ← Debounce de 3s para coalescer mensajes rápidos
|
||||||
|
│ ├── leads/
|
||||||
|
│ │ ├── leads.module.ts
|
||||||
|
│ │ ├── lead.entity.ts ← Entidad Lead con todos los campos
|
||||||
|
│ │ └── leads.service.ts ← CRUD, máquina de estados, viabilidad
|
||||||
|
│ ├── conversacion/
|
||||||
|
│ │ ├── conversacion.module.ts
|
||||||
|
│ │ ├── conversacion.entity.ts ← Entidad Conversacion (historial)
|
||||||
|
│ │ └── conversacion.service.ts ← Guardado y recuperación de historial
|
||||||
|
│ ├── claude/
|
||||||
|
│ │ ├── claude.module.ts
|
||||||
|
│ │ └── claude.service.ts ← Arquitectura de 4 capas con Claude
|
||||||
|
│ ├── media/
|
||||||
|
│ │ ├── media.module.ts
|
||||||
|
│ │ └── media.service.ts ← Transcripción de audio + análisis de imagen
|
||||||
|
│ └── scheduler/
|
||||||
|
│ ├── scheduler.module.ts
|
||||||
|
│ └── scheduler.service.ts ← Cron cada 5 min: apertura a leads nuevos + limpieza
|
||||||
|
├── .env.example
|
||||||
|
├── nest-cli.json
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
└── tsconfig.build.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura de procesamiento (4 capas con Claude)
|
||||||
|
|
||||||
|
Cada mensaje entrante pasa por 4 capas antes de responder:
|
||||||
|
|
||||||
|
```
|
||||||
|
Mensaje entrante (texto / audio / imagen)
|
||||||
|
↓
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ PREPROCESAMIENTO │
|
||||||
|
│ • Identificar lead por teléfono│
|
||||||
|
│ • Si audio → transcripción │
|
||||||
|
│ (Gemini 2.5 Flash via │
|
||||||
|
│ OpenRouter) │
|
||||||
|
│ • Si imagen → Vision │
|
||||||
|
│ (Claude Sonnet via │
|
||||||
|
│ OpenRouter) │
|
||||||
|
│ • Si texto → directo │
|
||||||
|
│ • Guardar mensaje usuario en │
|
||||||
|
│ DB │
|
||||||
|
└───────────┬───────────────────┘
|
||||||
|
↓
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ CAPA 1: CLASIFICADOR (Haiku) │
|
||||||
|
│ Extrae intención y valor │
|
||||||
|
│ del mensaje: │
|
||||||
|
│ • responde_pregunta: bool │
|
||||||
|
│ • valor_extraido: string|null │
|
||||||
|
│ • es_desvio: bool │
|
||||||
|
│ • intencion: respuesta | │
|
||||||
|
│ desvio | despedida | │
|
||||||
|
│ insulto | pregunta │
|
||||||
|
└───────────┬───────────────────┘
|
||||||
|
↓
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ CAPA 2: VALIDADOR (código) │
|
||||||
|
│ Valida contra valores │
|
||||||
|
│ permitidos del estado actual │
|
||||||
|
│ • espacios: cocina, bano, │
|
||||||
|
│ salon, integral, otro │
|
||||||
|
│ • tamaño: menos10, 10a20, │
|
||||||
|
│ 20a40, mas40 │
|
||||||
|
│ • estilo: funcional, cuidado, │
|
||||||
|
│ exclusivo │
|
||||||
|
│ • urgencia: urgente, │
|
||||||
|
│ medio_plazo, frio │
|
||||||
|
│ • presupuesto: evalúa │
|
||||||
|
│ viabilidad (>= 5000€) │
|
||||||
|
└───────────┬───────────────────┘
|
||||||
|
↓
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ CAPA 3: GENERADOR (Sonnet) │
|
||||||
|
│ Construye contexto y genera │
|
||||||
|
│ borrador de respuesta │
|
||||||
|
│ • Estado del lead │
|
||||||
|
│ • Datos capturados │
|
||||||
|
│ • Historial completo │
|
||||||
|
│ • Clasificación y validación │
|
||||||
|
│ • Reintentos │
|
||||||
|
└───────────┬───────────────────┘
|
||||||
|
↓
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ CAPA 4: REGLAS (Haiku) │
|
||||||
|
│ Corrige el borrador para │
|
||||||
|
│ cumplir identidad de Luisa: │
|
||||||
|
│ • Sin menciones a IA │
|
||||||
|
│ • Máximo 2 líneas │
|
||||||
|
│ • Sin emojis │
|
||||||
|
│ • Tono Madrid/España │
|
||||||
|
└───────────┬───────────────────┘
|
||||||
|
↓
|
||||||
|
Guardar respuesta en DB
|
||||||
|
↓
|
||||||
|
Enviar por Baileys
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables de entorno
|
||||||
|
|
||||||
|
### `.env.example`
|
||||||
|
|
||||||
|
```env
|
||||||
|
OPENROUTER_API_KEY= # (REQUERIDA) API key de OpenRouter
|
||||||
|
MODEL_GENERADOR=anthropic/claude-sonnet-4-5 # Modelo para generar respuestas (Capa 3)
|
||||||
|
MODEL_CLASIFICADOR=anthropic/claude-haiku-4-5 # Modelo para clasificar mensajes (Capa 1)
|
||||||
|
MODEL_REGLAS=anthropic/claude-haiku-4-5 # Modelo para aplicar reglas (Capa 4)
|
||||||
|
MODEL_TRANSCRIPCION=google/gemini-2.5-flash # Modelo para transcripción de audio
|
||||||
|
MODEL=anthropic/claude-sonnet-4-5 # Fallback general si no se especifica otro
|
||||||
|
DATABASE_URL= # (REQUERIDA) PostgreSQL connection string
|
||||||
|
ALLOWED_NUMBER= # (OPCIONAL) Restringe el bot a un solo número
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notas:**
|
||||||
|
- `ALLOWED_NUMBER` es opcional. Si se define, el bot **solo atiende mensajes de ese número**. Si se deja vacío, acepta mensajes de cualquier número de teléfono.
|
||||||
|
- La transcripción de audio usa **Gemini 2.5 Flash** por defecto porque Claude no soporta `input_audio` en OpenRouter. El resto del pipeline sigue usando Claude.
|
||||||
|
- Si `MODEL` está definido, sirve como fallback para `MODEL_GENERADOR` cuando no se especifica.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Configuración rápida
|
## Configuración rápida
|
||||||
|
|
||||||
### 1. Variables de entorno
|
### 1. Variables de entorno
|
||||||
@@ -34,32 +168,33 @@ Agente de WhatsApp **Luisa** para **Reformix** — cualifica leads de reforma de
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Edita `.env`:
|
Edita `.env` con tus valores reales:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
OPENROUTER_API_KEY=sk-or-...
|
OPENROUTER_API_KEY=sk-or-v1-tu-api-key-aqui
|
||||||
MODEL=anthropic/claude-sonnet-4-5
|
DATABASE_URL=postgresql://user:password@localhost:5432/reformix
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/reformix_luisa
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Base de datos
|
### 2. Base de datos
|
||||||
|
|
||||||
El proyecto usa `synchronize: true` en modo desarrollo, TypeORM creará las tablas automáticamente al arrancar.
|
El proyecto usa `synchronize: true` en modo desarrollo (definido en `src/app.module.ts`). TypeORM creará las tablas automáticamente al arrancar.
|
||||||
|
|
||||||
En producción, desactiva `synchronize` y usa migrations:
|
En producción, cambia `synchronize: false` y usa migrations:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run migration:generate
|
npm run migration:generate -- src/migrations/Init
|
||||||
npm run migration:run
|
npm run migration:run
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Prompts de Luisa
|
### 3. Prompts de Luisa
|
||||||
|
|
||||||
Rellena los 3 archivos en `/prompts` antes de arrancar:
|
Los 3 archivos de prompts ya están creados y contienen la configuración completa:
|
||||||
|
|
||||||
- `luisa_core.md` — identidad, tono, límites
|
- **`prompts/luisa_core.md`** — Identidad de Luisa, personalidad (español de Madrid, cercana, directa), máquina de estados obligatoria con mensajes exactos por estado, extracción de datos con bloque JSON `<DATOS_EXTRAIDOS>`, manejo de casos especiales (desvíos, reintentos, inactividad, multimedia, tono defensivo), y ejemplos few-shot.
|
||||||
- `luisa_flujo.md` — estados, preguntas por estado, condiciones de avance
|
- **`prompts/luisa_flujo.md`** — Secuencia de estados, campos DB con valores permitidos, y mensajes por estado (versión simplificada).
|
||||||
- `luisa_casos.md` — casos edge, fallbacks, ejemplos de conversación
|
- **`prompts/luisa_casos.md`** — Casos edge: desvíos, reintentos (máx 2), inactividad (24h/48h), manejo de audio/imagen/sticker, tono defensivo, usuario que no da presupuesto.
|
||||||
|
|
||||||
|
Puedes modificarlos libremente para ajustar el tono o comportamiento de Luisa.
|
||||||
|
|
||||||
### 4. Arrancar
|
### 4. Arrancar
|
||||||
|
|
||||||
@@ -68,61 +203,139 @@ npm install
|
|||||||
npm run start:dev
|
npm run start:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Escanea el **QR** que aparece en la terminal con WhatsApp.
|
Aparecerá un **código QR** en la terminal. Escanéalo con WhatsApp → **WhatsApp Web**.
|
||||||
|
|
||||||
Luisa queda conectada y lista.
|
Luisa queda conectada y lista para recibir mensajes.
|
||||||
|
|
||||||
## Flujo de mensajes
|
---
|
||||||
|
|
||||||
```
|
## Máquina de estados del lead
|
||||||
Mensaje entrante (texto / audio / imagen)
|
|
||||||
↓
|
|
||||||
Identificar lead por teléfono (crear si no existe)
|
|
||||||
↓
|
|
||||||
Si audio → Claude 4.5 transcripción
|
|
||||||
Si imagen → Claude 4.5 Vision (prompt según estado)
|
|
||||||
Si texto → directo
|
|
||||||
↓
|
|
||||||
Guardar mensaje usuario en DB
|
|
||||||
↓
|
|
||||||
Construir contexto: estado, datos del lead, historial, prompts MD
|
|
||||||
↓
|
|
||||||
Llamar Claude 4.5 via OpenRouter
|
|
||||||
↓
|
|
||||||
Extraer entidades del turno → actualizar lead en DB
|
|
||||||
↓
|
|
||||||
Evaluar flag viable → cambiar estado si aplica
|
|
||||||
↓
|
|
||||||
Guardar respuesta de Claude en DB
|
|
||||||
↓
|
|
||||||
Enviar respuesta por Baileys
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scheduler (cron cada 5 min)
|
|
||||||
|
|
||||||
- Busca leads con `estado_actual = 'nuevo'`
|
|
||||||
- Marca como `en_proceso` antes de actuar
|
|
||||||
- Genera y envía el mensaje de APERTURA de Luisa
|
|
||||||
- Ignora leads en `completado`, `no_viable`, `perdido`
|
|
||||||
- Marca como `perdido` leads en `en_proceso` sin actividad > 48h
|
|
||||||
|
|
||||||
## Estados del lead
|
|
||||||
|
|
||||||
| Estado | Descripción |
|
| Estado | Descripción |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `nuevo` | Lead creado, aún no contactado |
|
| `nuevo` | Lead creado, aún no contactado |
|
||||||
| `en_proceso` | Luisa le ha enviado el primer mensaje |
|
| `en_proceso` | El scheduler le ha enviado el primer mensaje (transición interna) |
|
||||||
| `recopilando_datos` | Conversación activa |
|
| `apertura` | Luisa se presenta y pregunta disponibilidad |
|
||||||
| `completado` | Todos los datos recogidos, viable=true |
|
| `espacio` | Pregunta: ¿qué espacio quieres reformar? |
|
||||||
| `no_viable` | Lead descartado, viable=false |
|
| `tamano` | Pregunta: ¿rango de metros cuadrados? |
|
||||||
|
| `estilo` | Pregunta: ¿tipo de acabado? |
|
||||||
|
| `urgencia` | Pregunta: ¿cuándo quieres empezar? |
|
||||||
|
| `presupuesto` | Pregunta: ¿presupuesto aproximado? |
|
||||||
|
| `fin_viable` | Transición interna → `completado` si presupuesto >= 5000€ |
|
||||||
|
| `fin_no_viable` | Transición interna → `no_viable` si presupuesto < 5000€ |
|
||||||
|
| `recopilando_datos` | Estado legacy, se normaliza a `apertura` |
|
||||||
|
| `completado` | Todos los datos recogidos y lead viable |
|
||||||
|
| `no_viable` | Lead descartado por presupuesto insuficiente |
|
||||||
| `perdido` | Sin actividad > 48h |
|
| `perdido` | Sin actividad > 48h |
|
||||||
|
|
||||||
|
**Secuencia completa:** `nuevo → apertura → espacio → tamano → estilo → urgencia → presupuesto → fin_viable/fin_no_viable`
|
||||||
|
|
||||||
|
### Datos recolectados por estado
|
||||||
|
|
||||||
|
| Estado | Campo DB | Valores válidos |
|
||||||
|
|--------|----------|-----------------|
|
||||||
|
| `espacio` | `espacio` | `cocina`, `bano`, `salon`, `integral`, `otro` |
|
||||||
|
| `tamano` | `rango_m2` | `menos10`, `10a20`, `20a40`, `mas40` |
|
||||||
|
| `estilo` | `estilo` | `funcional`, `cuidado`, `exclusivo` |
|
||||||
|
| `urgencia` | `urgencia` | `urgente`, `medio_plazo`, `frio` |
|
||||||
|
| `presupuesto` | `presupuesto_declarado` | Cifra o rango en euros |
|
||||||
|
|
||||||
|
### Evaluación de viabilidad
|
||||||
|
|
||||||
|
Un presupuesto es **viable** si su valor numérico es **>= 5000€**. Si no se puede determinar el valor, se asume viable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scheduler (cron cada 5 min)
|
||||||
|
|
||||||
|
El servicio `SchedulerService` se ejecuta cada 5 minutos vía `@nestjs/schedule` y realiza dos tareas:
|
||||||
|
|
||||||
|
1. **Limpiar leads inactivos**: Marca como `perdido` los leads en `en_proceso` sin actualización > 48h.
|
||||||
|
2. **Apertura de leads nuevos**: Busca leads con `estado_actual = 'nuevo'`, los marca como `en_proceso`, llama a Claude para generar el mensaje de apertura, lo guarda en el historial y lo envía por WhatsApp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debounce de mensajes
|
||||||
|
|
||||||
|
El servicio `WhatsappDebounceService` agrupa mensajes rápidos de un mismo usuario en una ventana de **3 segundos**. Si el usuario envía varios mensajes cortos seguidos, se concatenan en un solo texto antes de procesarlos. Esto evita que Claude reciba múltiples requests simultáneas por el mismo lead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restricción por número (ALLOWED_NUMBER)
|
||||||
|
|
||||||
|
Si se define `ALLOWED_NUMBER` en `.env`, el bot **ignora cualquier mensaje que no venga de ese número**. Esto es útil para pruebas o para desplegar una instancia dedicada a un solo cliente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Soporte multimedia
|
||||||
|
|
||||||
|
### Audio
|
||||||
|
- Se descarga el buffer del mensaje de audio via Baileys.
|
||||||
|
- Se detecta el formato real por magic bytes (Ogg, MP3, WAV).
|
||||||
|
- Se envía a OpenRouter con `input_audio` usando **Gemini 2.5 Flash** (modelo configurable via `MODEL_TRANSCRIPCION`).
|
||||||
|
- La transcripción conserva coloquialismos y jerga madrileña.
|
||||||
|
- Si falla o el buffer es muy pequeño, se responde con un mensaje de cortesía pidiendo repetición.
|
||||||
|
|
||||||
|
### Imagen
|
||||||
|
- Se descarga el buffer y se envía a OpenRouter con `image_url` usando **Claude Sonnet**.
|
||||||
|
- El prompt de análisis varía según el estado actual del lead:
|
||||||
|
- En `espacio`/`tamano`: infiere tipo de espacio y metros cuadrados.
|
||||||
|
- En `estilo`: infiere el estilo o calidad de acabados.
|
||||||
|
- En otros estados: descripción general.
|
||||||
|
- Si la imagen tiene caption, se combina con la inferencia.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manejo de errores y reconexión
|
||||||
|
|
||||||
|
- Si la conexión de WhatsApp se cierra por cualquier motivo que **no sea logout**, se reintenta automáticamente tras 5 segundos.
|
||||||
|
- Si hay un **logout** (sesión cerrada), se muestra un error pidiendo eliminar `auth_info_baileys` y reiniciar.
|
||||||
|
- Cada mensaje se procesa en un bloque `try/catch`; si falla, se loguea el error y se continúa con el siguiente mensaje.
|
||||||
|
- Si Claude falla repetidamente al clasificar, se usa un fallback conservador (desvío) para no bloquear la conversación.
|
||||||
|
- Si la respuesta generada contiene frases prohibidas ("soy un asistente", "ChatGPT", etc.), se reemplaza automáticamente con un `mensajeFallback` según el estado actual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts disponibles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # Compilar con NestJS
|
||||||
|
npm run start # Iniciar en producción
|
||||||
|
npm run start:dev # Iniciar en desarrollo con watch
|
||||||
|
npm run start:debug # Iniciar en modo debug
|
||||||
|
npm run lint # Ejecutar ESLint
|
||||||
|
npm run test # Ejecutar tests con Jest
|
||||||
|
npm run migration:generate # Generar migración de TypeORM
|
||||||
|
npm run migration:run # Ejecutar migraciones pendientes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Qué NO hace este servicio
|
## Qué NO hace este servicio
|
||||||
|
|
||||||
- No genera el presupuesto (lo hace otro worker)
|
- ❌ No genera el presupuesto (lo hace otro worker externo)
|
||||||
- No renderiza el PDF
|
- ❌ No renderiza el PDF
|
||||||
- No envía la URL (la inserta el worker en `url_presupuesto`)
|
- ❌ No envía la URL del presupuesto (la inserta el worker en `url_presupuesto`)
|
||||||
- No tiene panel del reformista
|
- ❌ No tiene panel del reformista
|
||||||
|
- ❌ No maneja conversaciones grupales (@g.us) — se ignoran explícitamente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desarrollo
|
||||||
|
|
||||||
|
### Requisitos
|
||||||
|
|
||||||
|
- Node.js >= 20
|
||||||
|
- PostgreSQL >= 14
|
||||||
|
- Cuenta en [OpenRouter](https://openrouter.ai) con API key
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test
|
||||||
|
npm run test:watch # Modo watch
|
||||||
|
npm run test:cov # Con cobertura
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
"target": "ES2021",
|
"target": "ES2021",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
3
mvp/b2c/.gitignore
vendored
3
mvp/b2c/.gitignore
vendored
@@ -43,3 +43,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
# Colección Postman con la FUNNEL_API_KEY embebida — no commitear
|
# Colección Postman con la FUNNEL_API_KEY embebida — no commitear
|
||||||
api-docs/reformix-ingesta.postman_collection.json
|
api-docs/reformix-ingesta.postman_collection.json
|
||||||
|
|
||||||
|
# Logs locales del dev server
|
||||||
|
dev.log
|
||||||
|
|||||||
3
mvp/b2c/drizzle/0009_white_agent_brand.sql
Normal file
3
mvp/b2c/drizzle/0009_white_agent_brand.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "leads" ADD COLUMN "anterior_a_2000" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "leads" ADD COLUMN "cambio_distribucion" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "pricing_config" ADD COLUMN "extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::jsonb NOT NULL;
|
||||||
1694
mvp/b2c/drizzle/meta/0009_snapshot.json
Normal file
1694
mvp/b2c/drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,13 @@
|
|||||||
"when": 1780505942614,
|
"when": 1780505942614,
|
||||||
"tag": "0008_sharp_bloodaxe",
|
"tag": "0008_sharp_bloodaxe",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780569557328,
|
||||||
|
"tag": "0009_white_agent_brand",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,7 @@ export async function actualizarConfig(formData: FormData) {
|
|||||||
alturaTechoDefault: parsePositive(formData.get('alturaTechoDefault'), 'altura de techo'),
|
alturaTechoDefault: parsePositive(formData.get('alturaTechoDefault'), 'altura de techo'),
|
||||||
manoObra: {
|
manoObra: {
|
||||||
demolicion: eurosToCents(formData.get('mo_demolicion'), 'demolición'),
|
demolicion: eurosToCents(formData.get('mo_demolicion'), 'demolición'),
|
||||||
|
impermeabilizacion: eurosToCents(formData.get('mo_impermeabilizacion'), 'impermeabilización'),
|
||||||
fontaneria: eurosToCents(formData.get('mo_fontaneria'), 'fontanería'),
|
fontaneria: eurosToCents(formData.get('mo_fontaneria'), 'fontanería'),
|
||||||
electricidad: eurosToCents(formData.get('mo_electricidad'), 'electricidad'),
|
electricidad: eurosToCents(formData.get('mo_electricidad'), 'electricidad'),
|
||||||
mano_de_obra: eurosToCents(formData.get('mo_mano_de_obra'), 'mano de obra'),
|
mano_de_obra: eurosToCents(formData.get('mo_mano_de_obra'), 'mano de obra'),
|
||||||
@@ -76,6 +77,22 @@ export async function actualizarConfig(formData: FormData) {
|
|||||||
revalidatePath('/panel/precios');
|
revalidatePath('/panel/precios');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function actualizarExtras(formData: FormData) {
|
||||||
|
const tenantId = await getTenantId();
|
||||||
|
await db
|
||||||
|
.update(pricingConfig)
|
||||||
|
.set({
|
||||||
|
extras: {
|
||||||
|
tuberias: eurosToCents(formData.get('extra_tuberias'), 'renovación de tuberías'),
|
||||||
|
boletin: eurosToCents(formData.get('extra_boletin'), 'boletín eléctrico'),
|
||||||
|
distribucion: eurosToCents(formData.get('extra_distribucion'), 'cambio de distribución'),
|
||||||
|
},
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(pricingConfig.tenantId, tenantId));
|
||||||
|
revalidatePath('/panel/precios');
|
||||||
|
}
|
||||||
|
|
||||||
export async function actualizarEnvio(formData: FormData) {
|
export async function actualizarEnvio(formData: FormData) {
|
||||||
const tenantId = await getTenantId();
|
const tenantId = await getTenantId();
|
||||||
const modo = formData.get('modo');
|
const modo = formData.get('modo');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
actualizarPrecio,
|
actualizarPrecio,
|
||||||
borrarMaterial,
|
borrarMaterial,
|
||||||
actualizarConfig,
|
actualizarConfig,
|
||||||
|
actualizarExtras,
|
||||||
actualizarEnvio,
|
actualizarEnvio,
|
||||||
importarCatalogoCsv,
|
importarCatalogoCsv,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
@@ -96,6 +97,7 @@ export default async function PreciosPage() {
|
|||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
['demolicion', 'Demolición'],
|
['demolicion', 'Demolición'],
|
||||||
|
['impermeabilizacion', 'Impermeabilización'],
|
||||||
['fontaneria', 'Fontanería'],
|
['fontaneria', 'Fontanería'],
|
||||||
['electricidad', 'Electricidad'],
|
['electricidad', 'Electricidad'],
|
||||||
['mano_de_obra', 'Mano de obra'],
|
['mano_de_obra', 'Mano de obra'],
|
||||||
@@ -118,6 +120,39 @@ export default async function PreciosPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Extras fijos */}
|
||||||
|
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h2 className="font-bold text-black mb-1">Extras fijos</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Importes fijos que no escalan con los metros. El boletín eléctrico se aplica siempre; las
|
||||||
|
tuberías solo en pisos anteriores al año 2000 y la distribución al mover inodoro, ducha o
|
||||||
|
bañera.
|
||||||
|
</p>
|
||||||
|
<form action={actualizarExtras} className="grid grid-cols-2 md:grid-cols-3 gap-3 items-end">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['tuberias', 'Renovación de tuberías'],
|
||||||
|
['boletin', 'Boletín eléctrico'],
|
||||||
|
['distribucion', 'Cambio de distribución'],
|
||||||
|
] as const
|
||||||
|
).map(([k, etiqueta]) => (
|
||||||
|
<label key={k} className="text-sm">
|
||||||
|
<span className="block text-xs text-gray-500 mb-1">{etiqueta} (€)</span>
|
||||||
|
<input
|
||||||
|
name={`extra_${k}`}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
defaultValue={(config.extras?.[k] ?? 0) / 100}
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-2 py-1.5"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<button className="col-span-2 md:col-span-3 justify-self-start bg-primary-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||||
|
Guardar extras
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Catálogo por categoría */}
|
{/* Catálogo por categoría */}
|
||||||
{CATEGORIAS.map((categoria) => {
|
{CATEGORIAS.map((categoria) => {
|
||||||
const items = catalog.filter((c) => c.categoria === categoria);
|
const items = catalog.filter((c) => c.categoria === categoria);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
|
|||||||
import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell';
|
import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell';
|
||||||
import { iniciarConversacionWhatsapp } from '@/lib/webhooks';
|
import { iniciarConversacionWhatsapp } from '@/lib/webhooks';
|
||||||
import { enviarEnlaceFormulario } from '@/lib/email/mailer';
|
import { enviarEnlaceFormulario } from '@/lib/email/mailer';
|
||||||
|
import { resolveTheme } from '@/lib/funnel/themes';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
|
|
||||||
const MAX_ZONAS = 6;
|
const MAX_ZONAS = 6;
|
||||||
@@ -146,6 +147,8 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
|
|||||||
const presupuestoTarget =
|
const presupuestoTarget =
|
||||||
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
|
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
|
||||||
const estructural = formData.get('estructural') === 'on';
|
const estructural = formData.get('estructural') === 'on';
|
||||||
|
const anteriorA2000 = formData.get('anteriorA2000') === 'on';
|
||||||
|
const cambioDistribucion = formData.get('cambioDistribucion') === 'on';
|
||||||
|
|
||||||
let zonas = await parsearZonas(formData);
|
let zonas = await parsearZonas(formData);
|
||||||
if (zonas.length === 0) {
|
if (zonas.length === 0) {
|
||||||
@@ -191,6 +194,8 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
|
|||||||
urgencia,
|
urgencia,
|
||||||
presupuestoTarget,
|
presupuestoTarget,
|
||||||
estructural,
|
estructural,
|
||||||
|
anteriorA2000,
|
||||||
|
cambioDistribucion,
|
||||||
tasteText,
|
tasteText,
|
||||||
pipelineStage: 'fotos_subidas',
|
pipelineStage: 'fotos_subidas',
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -252,12 +257,19 @@ export async function enviarEnlaceFormularioEmail(leadId: string): Promise<boole
|
|||||||
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
||||||
if (!lead) return false;
|
if (!lead) return false;
|
||||||
const tenant = await getTenantPerfilById(lead.tenantId);
|
const tenant = await getTenantPerfilById(lead.tenantId);
|
||||||
|
const theme = resolveTheme(tenant.themePreset, tenant.themeColor);
|
||||||
const url = `${env.APP_URL ?? ''}/solicitud/${leadId}/formulario`;
|
const url = `${env.APP_URL ?? ''}/solicitud/${leadId}/formulario`;
|
||||||
return enviarEnlaceFormulario({
|
return enviarEnlaceFormulario({
|
||||||
to: lead.email,
|
to: lead.email,
|
||||||
nombre: lead.nombre,
|
nombre: lead.nombre,
|
||||||
empresa: tenant.nombreEmpresa,
|
empresa: tenant.nombreEmpresa,
|
||||||
url,
|
url,
|
||||||
|
brand: {
|
||||||
|
primary: theme.primary,
|
||||||
|
primaryDark: theme.primaryDark,
|
||||||
|
contrast: theme.contrast,
|
||||||
|
logoUrl: tenant.logoUrl,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,27 @@ import type {
|
|||||||
CatalogItem,
|
CatalogItem,
|
||||||
PartidaKey,
|
PartidaKey,
|
||||||
PricingConfig,
|
PricingConfig,
|
||||||
|
TipoReforma,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const LICENCIA_MIN = 30000; // 300 €
|
const LICENCIA_MIN = 30000; // 300 €
|
||||||
const LICENCIA_MAX = 150000; // 1.500 €
|
const LICENCIA_MAX = 150000; // 1.500 €
|
||||||
|
|
||||||
|
// Zonas húmedas: las únicas que llevan impermeabilización.
|
||||||
|
const WET = new Set<TipoReforma>(['cocina', 'bano', 'integral']);
|
||||||
|
|
||||||
|
// Intensidad de instalaciones (fontanería/electricidad) por m² según el tipo de reforma.
|
||||||
|
// Baseline cocina = 1.0. Un baño concentra más instalaciones por m²; un salón o un piso
|
||||||
|
// integral las diluye. Corrige el sesgo del modelo lineal €/m² sin rehacerlo.
|
||||||
|
const TIPO_INTENSIDAD: Record<TipoReforma, number> = {
|
||||||
|
cocina: 1.0,
|
||||||
|
bano: 1.3,
|
||||||
|
integral: 0.45,
|
||||||
|
salon: 0.4,
|
||||||
|
comedor: 0.4,
|
||||||
|
otro: 0.7,
|
||||||
|
};
|
||||||
|
|
||||||
// A qué partida contribuye el material de cada categoría.
|
// A qué partida contribuye el material de cada categoría.
|
||||||
const MATERIAL_PARTIDA: Record<CategoriaMaterial, PartidaKey> = {
|
const MATERIAL_PARTIDA: Record<CategoriaMaterial, PartidaKey> = {
|
||||||
suelo: 'alicatado',
|
suelo: 'alicatado',
|
||||||
@@ -34,12 +50,14 @@ export function computeBudget(
|
|||||||
|
|
||||||
const importes: Record<PartidaKey, number> = {
|
const importes: Record<PartidaKey, number> = {
|
||||||
demolicion: 0,
|
demolicion: 0,
|
||||||
|
impermeabilizacion: 0,
|
||||||
alicatado: 0,
|
alicatado: 0,
|
||||||
fontaneria: 0,
|
fontaneria: 0,
|
||||||
electricidad: 0,
|
electricidad: 0,
|
||||||
carpinteria: 0,
|
carpinteria: 0,
|
||||||
mano_de_obra: 0,
|
mano_de_obra: 0,
|
||||||
extras: 0,
|
extras: 0,
|
||||||
|
extras_fijos: 0,
|
||||||
licencia: 0,
|
licencia: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,11 +85,25 @@ export function computeBudget(
|
|||||||
if (item.descriptorRender) materialesRender.push(item.descriptorRender);
|
if (item.descriptorRender) materialesRender.push(item.descriptorRender);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const intensidad = TIPO_INTENSIDAD[inputs.tipoReforma] ?? 1;
|
||||||
importes.demolicion += Math.round(cant.m2Suelo * config.manoObra.demolicion);
|
importes.demolicion += Math.round(cant.m2Suelo * config.manoObra.demolicion);
|
||||||
importes.fontaneria += Math.round(cant.m2Suelo * config.manoObra.fontaneria);
|
importes.fontaneria += Math.round(cant.m2Suelo * config.manoObra.fontaneria * intensidad);
|
||||||
importes.electricidad += Math.round(cant.m2Suelo * config.manoObra.electricidad);
|
importes.electricidad += Math.round(cant.m2Suelo * config.manoObra.electricidad * intensidad);
|
||||||
importes.mano_de_obra += Math.round(cant.m2Suelo * config.manoObra.mano_de_obra);
|
importes.mano_de_obra += Math.round(cant.m2Suelo * config.manoObra.mano_de_obra);
|
||||||
|
|
||||||
|
// Impermeabilización: solo en zonas húmedas, proporcional al suelo a tratar.
|
||||||
|
if (WET.has(inputs.tipoReforma)) {
|
||||||
|
importes.impermeabilizacion += Math.round(cant.m2Suelo * config.manoObra.impermeabilizacion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extras fijos (no escalan con m²). El boletín eléctrico es siempre obligatorio.
|
||||||
|
const extras = config.extras;
|
||||||
|
if (extras) {
|
||||||
|
importes.extras_fijos += extras.boletin;
|
||||||
|
if (inputs.anteriorA2000) importes.extras_fijos += extras.tuberias;
|
||||||
|
if (inputs.cambioDistribucion) importes.extras_fijos += extras.distribucion;
|
||||||
|
}
|
||||||
|
|
||||||
if (inputs.estructural) importes.licencia += LICENCIA_MIN;
|
if (inputs.estructural) importes.licencia += LICENCIA_MIN;
|
||||||
|
|
||||||
const partidas = PARTIDA_ORDER.filter((k) => importes[k] > 0).map((k) => ({
|
const partidas = PARTIDA_ORDER.filter((k) => importes[k] > 0).map((k) => ({
|
||||||
|
|||||||
@@ -2,22 +2,26 @@ import type { PartidaKey } from './types';
|
|||||||
|
|
||||||
export const PARTIDA_ORDER: PartidaKey[] = [
|
export const PARTIDA_ORDER: PartidaKey[] = [
|
||||||
'demolicion',
|
'demolicion',
|
||||||
|
'impermeabilizacion',
|
||||||
'alicatado',
|
'alicatado',
|
||||||
'fontaneria',
|
'fontaneria',
|
||||||
'electricidad',
|
'electricidad',
|
||||||
'carpinteria',
|
'carpinteria',
|
||||||
'mano_de_obra',
|
'mano_de_obra',
|
||||||
'extras',
|
'extras',
|
||||||
|
'extras_fijos',
|
||||||
'licencia',
|
'licencia',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PARTIDA_LABEL: Record<PartidaKey, string> = {
|
export const PARTIDA_LABEL: Record<PartidaKey, string> = {
|
||||||
demolicion: 'Demolición',
|
demolicion: 'Demolición',
|
||||||
|
impermeabilizacion: 'Impermeabilización',
|
||||||
alicatado: 'Alicatado y solado',
|
alicatado: 'Alicatado y solado',
|
||||||
fontaneria: 'Fontanería',
|
fontaneria: 'Fontanería',
|
||||||
electricidad: 'Electricidad',
|
electricidad: 'Electricidad',
|
||||||
carpinteria: 'Carpintería y mobiliario',
|
carpinteria: 'Carpintería y mobiliario',
|
||||||
mano_de_obra: 'Mano de obra',
|
mano_de_obra: 'Mano de obra',
|
||||||
extras: 'Pintura y extras',
|
extras: 'Pintura y extras',
|
||||||
|
extras_fijos: 'Extras (tuberías, boletín, distribución)',
|
||||||
licencia: 'Licencia + Proyecto técnico',
|
licencia: 'Licencia + Proyecto técnico',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,15 +5,22 @@ export type TipoReforma = 'cocina' | 'bano' | 'salon' | 'comedor' | 'integral' |
|
|||||||
|
|
||||||
export type PartidaKey =
|
export type PartidaKey =
|
||||||
| 'demolicion'
|
| 'demolicion'
|
||||||
|
| 'impermeabilizacion'
|
||||||
| 'alicatado'
|
| 'alicatado'
|
||||||
| 'fontaneria'
|
| 'fontaneria'
|
||||||
| 'electricidad'
|
| 'electricidad'
|
||||||
| 'carpinteria'
|
| 'carpinteria'
|
||||||
| 'mano_de_obra'
|
| 'mano_de_obra'
|
||||||
| 'extras'
|
| 'extras'
|
||||||
|
| 'extras_fijos'
|
||||||
| 'licencia';
|
| 'licencia';
|
||||||
|
|
||||||
export type ManoObraKey = 'demolicion' | 'fontaneria' | 'electricidad' | 'mano_de_obra';
|
export type ManoObraKey =
|
||||||
|
| 'demolicion'
|
||||||
|
| 'impermeabilizacion'
|
||||||
|
| 'fontaneria'
|
||||||
|
| 'electricidad'
|
||||||
|
| 'mano_de_obra';
|
||||||
|
|
||||||
export interface CatalogItem {
|
export interface CatalogItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,10 +34,18 @@ export interface CatalogItem {
|
|||||||
sku: string;
|
sku: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extras fijos que no escalan con los m² (céntimos). Se aplican según el estado del piso.
|
||||||
|
export interface ExtrasFijos {
|
||||||
|
tuberias: number; // renovación de tuberías (pisos anteriores a 2000)
|
||||||
|
boletin: number; // boletín eléctrico (siempre obligatorio)
|
||||||
|
distribucion: number; // cambio de distribución (mover inodoro/ducha/bañera)
|
||||||
|
}
|
||||||
|
|
||||||
export interface PricingConfig {
|
export interface PricingConfig {
|
||||||
alturaTechoDefault: number; // metros
|
alturaTechoDefault: number; // metros
|
||||||
factorZona: Record<string, number>; // provincia -> multiplicador
|
factorZona: Record<string, number>; // provincia -> multiplicador
|
||||||
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
|
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
|
||||||
|
extras?: ExtrasFijos; // importes fijos en céntimos
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BudgetInputs {
|
export interface BudgetInputs {
|
||||||
@@ -41,6 +56,8 @@ export interface BudgetInputs {
|
|||||||
estructural: boolean;
|
estructural: boolean;
|
||||||
provincia: string | null;
|
provincia: string | null;
|
||||||
materialSelections: Partial<Record<CategoriaMaterial, string>>; // categoria -> catalogItemId
|
materialSelections: Partial<Record<CategoriaMaterial, string>>; // categoria -> catalogItemId
|
||||||
|
anteriorA2000?: boolean; // dispara el extra de renovación de tuberías
|
||||||
|
cambioDistribucion?: boolean; // dispara el extra de cambio de distribución
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartidaResult {
|
export interface PartidaResult {
|
||||||
|
|||||||
@@ -271,10 +271,20 @@ export default function FormularioZonas({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
|
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
|
||||||
<input type="checkbox" name="estructural" className="w-4 h-4 accent-black" />
|
<input type="checkbox" name="estructural" className="w-4 h-4 accent-black" />
|
||||||
Hay que mover sanitarios, tirar algún muro o cambiar la distribución
|
Hay que tirar algún muro u obra estructural
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
|
||||||
|
<input type="checkbox" name="cambioDistribucion" className="w-4 h-4 accent-black" />
|
||||||
|
Hay que mover el inodoro, la ducha o la bañera
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
|
||||||
|
<input type="checkbox" name="anteriorA2000" className="w-4 h-4 accent-black" />
|
||||||
|
El piso es anterior al año 2000
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubmitButton />
|
<SubmitButton />
|
||||||
|
|||||||
@@ -21,11 +21,14 @@ export async function getEnvioMode(): Promise<EnvioMode> {
|
|||||||
|
|
||||||
const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = {
|
const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = {
|
||||||
demolicion: 0,
|
demolicion: 0,
|
||||||
|
impermeabilizacion: 0,
|
||||||
fontaneria: 0,
|
fontaneria: 0,
|
||||||
electricidad: 0,
|
electricidad: 0,
|
||||||
mano_de_obra: 0,
|
mano_de_obra: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EXTRAS_DEFAULT = { tuberias: 0, boletin: 0, distribucion: 0 };
|
||||||
|
|
||||||
export async function getPricingConfigFor(tenantId: string): Promise<PricingConfig> {
|
export async function getPricingConfigFor(tenantId: string): Promise<PricingConfig> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -34,12 +37,18 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return { alturaTechoDefault: 2.5, factorZona: {}, manoObra: { ...MANO_OBRA_DEFAULT } };
|
return {
|
||||||
|
alturaTechoDefault: 2.5,
|
||||||
|
factorZona: {},
|
||||||
|
manoObra: { ...MANO_OBRA_DEFAULT },
|
||||||
|
extras: { ...EXTRAS_DEFAULT },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
alturaTechoDefault: row.alturaTechoDefault,
|
alturaTechoDefault: row.alturaTechoDefault,
|
||||||
factorZona: row.factorZona,
|
factorZona: row.factorZona,
|
||||||
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
|
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
|
||||||
|
extras: { ...EXTRAS_DEFAULT, ...(row.extras ?? {}) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ export const leads = pgTable(
|
|||||||
alturaTecho: doublePrecision('altura_techo'),
|
alturaTecho: doublePrecision('altura_techo'),
|
||||||
calidadGlobal: calidad('calidad_global'),
|
calidadGlobal: calidad('calidad_global'),
|
||||||
estructural: boolean('estructural').notNull().default(false),
|
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')
|
materialSelections: jsonb('material_selections')
|
||||||
.$type<Record<string, string>>()
|
.$type<Record<string, string>>()
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -340,6 +343,11 @@ export const pricingConfig = pgTable('pricing_config', {
|
|||||||
alturaTechoDefault: doublePrecision('altura_techo_default').notNull().default(2.5),
|
alturaTechoDefault: doublePrecision('altura_techo_default').notNull().default(2.5),
|
||||||
factorZona: jsonb('factor_zona').$type<Record<string, number>>().notNull().default({}),
|
factorZona: jsonb('factor_zona').$type<Record<string, number>>().notNull().default({}),
|
||||||
manoObra: jsonb('mano_obra').$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(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,26 @@ const db = drizzle(client, { schema });
|
|||||||
|
|
||||||
const euros = (n: number) => Math.round(n * 100); // a céntimos
|
const euros = (n: number) => Math.round(n * 100); // a céntimos
|
||||||
|
|
||||||
|
// Factor de zona geográfica por provincia/ciudad. Las no listadas valen 1.0 (media nacional).
|
||||||
|
// Tramos: Madrid/Barcelona 1.40, islas 1.30, capitales grandes 1.20, rural/interior 0.85.
|
||||||
|
const ZONA_FACTORES: Record<string, number> = Object.fromEntries(
|
||||||
|
[
|
||||||
|
[['Madrid', 'Barcelona'], 1.4],
|
||||||
|
[
|
||||||
|
['Baleares', 'Islas Baleares', 'Palma', 'Mallorca', 'Las Palmas', 'Tenerife', 'Santa Cruz de Tenerife', 'Canarias'],
|
||||||
|
1.3,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
['Valencia', 'Sevilla', 'Málaga', 'Bilbao', 'Vizcaya', 'Bizkaia', 'Zaragoza', 'Alicante', 'Murcia', 'San Sebastián', 'Gipuzkoa', 'Vitoria', 'Granada', 'Valladolid'],
|
||||||
|
1.2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
['Cuenca', 'Teruel', 'Soria', 'Zamora', 'Ávila', 'Palencia', 'Ourense', 'Lugo', 'Cáceres', 'Badajoz', 'Ciudad Real', 'Albacete', 'Jaén', 'Huesca', 'Segovia', 'Guadalajara'],
|
||||||
|
0.85,
|
||||||
|
],
|
||||||
|
].flatMap(([nombres, factor]) => (nombres as string[]).map((n) => [n, factor as number])),
|
||||||
|
);
|
||||||
|
|
||||||
// Cada lead vive en un momento distinto del funnel para poder analizar
|
// Cada lead vive en un momento distinto del funnel para poder analizar
|
||||||
// cuál es el siguiente paso de cada uno. days = hace cuántos días entró.
|
// cuál es el siguiente paso de cada uno. days = hace cuántos días entró.
|
||||||
type SeedLead = {
|
type SeedLead = {
|
||||||
@@ -513,8 +533,15 @@ async function main() {
|
|||||||
.values({
|
.values({
|
||||||
tenantId: tenantRow.id,
|
tenantId: tenantRow.id,
|
||||||
alturaTechoDefault: 2.5,
|
alturaTechoDefault: 2.5,
|
||||||
factorZona: { Madrid: 1.1, Barcelona: 1.15, Valencia: 1.0, Sevilla: 0.95 },
|
factorZona: ZONA_FACTORES,
|
||||||
manoObra: { demolicion: 1800, fontaneria: 2200, electricidad: 1600, mano_de_obra: 3500 },
|
manoObra: {
|
||||||
|
demolicion: 5000,
|
||||||
|
impermeabilizacion: 4500,
|
||||||
|
fontaneria: 14600,
|
||||||
|
electricidad: 5400,
|
||||||
|
mano_de_obra: 7500,
|
||||||
|
},
|
||||||
|
extras: { tuberias: 115000, boletin: 17500, distribucion: 90000 },
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -539,12 +566,12 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const catalog = await db.insert(schema.catalogItems).values([
|
const catalog = await db.insert(schema.catalogItems).values([
|
||||||
cat('suelo', 'Gres cerámico básico', 'basica', 16, 'm2', 'suelo gres beige liso', 'SUE-B'),
|
cat('suelo', 'Gres cerámico básico', 'basica', 40, 'm2', 'suelo gres beige liso', 'SUE-B'),
|
||||||
cat('suelo', 'Porcelánico símil madera', 'media', 28, 'm2', 'porcelánico símil roble claro', 'SUE-M'),
|
cat('suelo', 'Porcelánico símil madera', 'media', 70, 'm2', 'porcelánico símil roble claro', 'SUE-M'),
|
||||||
cat('suelo', 'Porcelánico gran formato', 'premium', 48, 'm2', 'porcelánico gran formato gris piedra', 'SUE-P'),
|
cat('suelo', 'Porcelánico gran formato', 'premium', 170, 'm2', 'porcelánico gran formato gris piedra', 'SUE-P'),
|
||||||
cat('pared', 'Azulejo blanco brillo', 'basica', 14, 'm2', 'azulejo blanco brillo 20x20', 'PAR-B'),
|
cat('pared', 'Azulejo blanco brillo', 'basica', 32, 'm2', 'azulejo blanco brillo 20x20', 'PAR-B'),
|
||||||
cat('pared', 'Azulejo rectificado', 'media', 24, 'm2', 'azulejo rectificado blanco mate 30x60', 'PAR-M'),
|
cat('pared', 'Azulejo rectificado', 'media', 60, 'm2', 'azulejo rectificado blanco mate 30x60', 'PAR-M'),
|
||||||
cat('pared', 'Porcelánico decorativo', 'premium', 42, 'm2', 'porcelánico decorativo símil mármol', 'PAR-P'),
|
cat('pared', 'Porcelánico decorativo', 'premium', 140, 'm2', 'porcelánico decorativo símil mármol', 'PAR-P'),
|
||||||
cat('pintura', 'Plástica mate', 'basica', 6, 'm2', 'pintura plástica blanca mate', 'PIN-B'),
|
cat('pintura', 'Plástica mate', 'basica', 6, 'm2', 'pintura plástica blanca mate', 'PIN-B'),
|
||||||
cat('pintura', 'Plástica lavable', 'media', 9, 'm2', 'pintura lavable blanco roto', 'PIN-M'),
|
cat('pintura', 'Plástica lavable', 'media', 9, 'm2', 'pintura lavable blanco roto', 'PIN-M'),
|
||||||
cat('pintura', 'Esmalte premium', 'premium', 14, 'm2', 'esmalte al agua acabado seda gris perla', 'PIN-P'),
|
cat('pintura', 'Esmalte premium', 'premium', 14, 'm2', 'esmalte al agua acabado seda gris perla', 'PIN-P'),
|
||||||
|
|||||||
@@ -23,6 +23,154 @@ function escapeHtml(s: string): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const primerNombre = (nombre: string) => nombre.split(' ')[0] || nombre;
|
||||||
|
|
||||||
|
// Color de marca del reformista para el acento del email (botón, barra, enlaces).
|
||||||
|
export type EmailBrand = {
|
||||||
|
primary: string;
|
||||||
|
primaryDark: string;
|
||||||
|
contrast: string;
|
||||||
|
logoUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BRAND_DEFECTO: EmailBrand = { primary: '#0a0a0a', primaryDark: '#1a1a1a', contrast: '#ffffff' };
|
||||||
|
|
||||||
|
type EmailParts = {
|
||||||
|
brand: EmailBrand;
|
||||||
|
empresa: string;
|
||||||
|
preheader: string;
|
||||||
|
headline: string;
|
||||||
|
parrafosHtml: string[]; // HTML seguro: las variables ya vienen escapadas
|
||||||
|
cta?: { url: string; label: string } | null;
|
||||||
|
footer: string;
|
||||||
|
logoCid?: string | null; // si hay logo, se incrusta como adjunto inline (cid:)
|
||||||
|
};
|
||||||
|
|
||||||
|
type LogoAdjunto = { filename: string; content?: Buffer; path?: string; cid: string; contentType?: string };
|
||||||
|
|
||||||
|
const LOGO_CID = 'logoempresa';
|
||||||
|
|
||||||
|
// Prepara el logo como adjunto inline (CID) para que se vea en todos los clientes (Gmail bloquea
|
||||||
|
// las imágenes en data URI directas). Acepta data URI (base64), URL http(s), o ruta /public
|
||||||
|
// (se vuelve absoluta con APP_URL). Devuelve null si no hay logo usable.
|
||||||
|
function prepararLogo(logoUrl: string | null | undefined): LogoAdjunto | null {
|
||||||
|
if (!logoUrl) return null;
|
||||||
|
if (logoUrl.startsWith('data:')) {
|
||||||
|
const coma = logoUrl.indexOf(',');
|
||||||
|
if (coma === -1) return null;
|
||||||
|
const mime = logoUrl.slice(5, coma).split(';')[0] || 'image/png';
|
||||||
|
const ext = mime.split('/')[1]?.replace('+xml', '') || 'png';
|
||||||
|
const content = Buffer.from(logoUrl.slice(coma + 1), 'base64');
|
||||||
|
return { filename: `logo.${ext}`, content, cid: LOGO_CID, contentType: mime };
|
||||||
|
}
|
||||||
|
if (/^https?:\/\//.test(logoUrl)) return { filename: 'logo', path: logoUrl, cid: LOGO_CID };
|
||||||
|
if (logoUrl.startsWith('/') && env.APP_URL) {
|
||||||
|
return { filename: 'logo', path: `${env.APP_URL}${logoUrl}`, cid: LOGO_CID };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
|
||||||
|
|
||||||
|
// Email transaccional: una columna (máx 600px), mobile-first, dark mode, botón bulletproof (tabla),
|
||||||
|
// estilos inline como base + <style> para dark/responsive. Compatible con Gmail/Apple Mail/Outlook.
|
||||||
|
function construirEmailHtml(p: EmailParts): string {
|
||||||
|
const empresa = escapeHtml(p.empresa);
|
||||||
|
const logo = p.logoCid
|
||||||
|
? `<img src="cid:${p.logoCid}" alt="${empresa}" height="40" style="max-height:40px;max-width:200px;display:block;border:0;" />`
|
||||||
|
: `<span class="email-logo-name" style="font-size:18px;font-weight:700;color:#1a1a1a;letter-spacing:-0.2px;">${empresa}</span>`;
|
||||||
|
|
||||||
|
const cta = p.cta
|
||||||
|
? `<tr><td style="height:28px;line-height:28px;"> </td></tr>
|
||||||
|
<tr><td align="center">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 auto;">
|
||||||
|
<tr><td style="border-radius:10px;background:${p.brand.primary};">
|
||||||
|
<a class="btn-a" href="${escapeHtml(p.cta.url)}" target="_blank"
|
||||||
|
style="display:inline-block;padding:15px 30px;font-family:${FONT};font-size:16px;font-weight:600;line-height:1;color:${p.brand.contrast};text-decoration:none;border-radius:10px;">
|
||||||
|
${escapeHtml(p.cta.label)}
|
||||||
|
</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const parrafos = p.parrafosHtml
|
||||||
|
.map(
|
||||||
|
(html) =>
|
||||||
|
`<tr><td class="email-text" style="font-family:${FONT};font-size:16px;line-height:1.65;color:#3f3f46;padding-bottom:16px;">${html}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="es" style="margin:0;padding:0;">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="supported-color-schemes" content="light dark" />
|
||||||
|
<title>${empresa}</title>
|
||||||
|
<style>
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.email-body { background:#0b0b0c !important; }
|
||||||
|
.email-card { background:#18181b !important; }
|
||||||
|
.email-head { background:#18181b !important; }
|
||||||
|
.email-title { color:#fafafa !important; }
|
||||||
|
.email-text { color:#d4d4d8 !important; }
|
||||||
|
.email-muted { color:#a1a1aa !important; }
|
||||||
|
.email-rule { border-color:#27272a !important; }
|
||||||
|
.email-logo-name { color:#fafafa !important; }
|
||||||
|
}
|
||||||
|
@media (max-width:600px) {
|
||||||
|
.email-card { width:100% !important; border-radius:0 !important; }
|
||||||
|
.email-pad { padding-left:24px !important; padding-right:24px !important; }
|
||||||
|
.btn-a { display:block !important; padding-left:0 !important; padding-right:0 !important; width:100%; box-sizing:border-box; text-align:center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="email-body" style="margin:0;padding:0;background:#f4f4f5;">
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">${escapeHtml(p.preheader)}</div>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f4f4f5;">
|
||||||
|
<tr><td align="center" style="padding:32px 12px;">
|
||||||
|
<table role="presentation" class="email-card" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;">
|
||||||
|
<tr><td style="height:4px;line-height:4px;background:${p.brand.primary};font-size:0;"> </td></tr>
|
||||||
|
<tr><td class="email-head email-pad" style="padding:24px 40px 8px 40px;">
|
||||||
|
${logo}
|
||||||
|
</td></tr>
|
||||||
|
<tr><td class="email-pad" style="padding:8px 40px 0 40px;">
|
||||||
|
<h1 class="email-title" style="margin:0 0 18px 0;font-family:${FONT};font-size:26px;line-height:1.25;font-weight:800;color:#18181b;letter-spacing:-0.4px;">${escapeHtml(p.headline)}</h1>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
${parrafos}
|
||||||
|
${cta}
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td class="email-pad" style="padding:28px 40px 0 40px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr><td class="email-rule" style="border-top:1px solid #e4e4e7;font-size:0;line-height:0;"> </td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td class="email-pad email-muted" style="padding:16px 40px 32px 40px;font-family:${FONT};font-size:13px;line-height:1.5;color:#71717a;">${escapeHtml(p.footer)}</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function construirEmailText(p: EmailParts): string {
|
||||||
|
const lineas = [p.headline, '', ...p.parrafosHtml.map(stripHtml), ''];
|
||||||
|
if (p.cta) lineas.push(`${p.cta.label}: ${p.cta.url}`, '');
|
||||||
|
lineas.push('—', p.footer);
|
||||||
|
return lineas.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripHtml = (html: string) =>
|
||||||
|
html
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
|
||||||
// Email de entrega del presupuesto con el PDF adjunto (COPY-GUIDE §6.b). Best-effort: si no hay
|
// Email de entrega del presupuesto con el PDF adjunto (COPY-GUIDE §6.b). Best-effort: si no hay
|
||||||
// SMTP configurado o el envío falla devuelve false sin lanzar, para no romper el pipeline.
|
// SMTP configurado o el envío falla devuelve false sin lanzar, para no romper el pipeline.
|
||||||
export async function enviarPresupuestoEmail(opts: {
|
export async function enviarPresupuestoEmail(opts: {
|
||||||
@@ -31,25 +179,46 @@ export async function enviarPresupuestoEmail(opts: {
|
|||||||
empresa: string;
|
empresa: string;
|
||||||
pdf: Buffer;
|
pdf: Buffer;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
brand?: EmailBrand;
|
||||||
|
cta?: { url: string; label: string } | null;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const transport = getTransport();
|
const transport = getTransport();
|
||||||
if (!transport) return false;
|
if (!transport) return false;
|
||||||
|
|
||||||
const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre);
|
const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
|
||||||
const empresa = escapeHtml(opts.empresa);
|
const logo = prepararLogo(opts.brand?.logoUrl);
|
||||||
const html = `<p>Hola ${nombre},</p>
|
const parts: EmailParts = {
|
||||||
<p>Aquí tienes tu <strong>presupuesto orientativo de reforma</strong>, preparado por ${empresa}. Lo encontrarás adjunto en PDF, con el render de cómo quedaría tu espacio y el desglose por partidas.</p>
|
brand: opts.brand ?? BRAND_DEFECTO,
|
||||||
<p>⚠️ Es una <strong>estimación</strong>. El precio definitivo lo confirma ${empresa} en una visita gratuita en tu casa, donde mide todo con detalle y lo ajusta.</p>
|
empresa: opts.empresa,
|
||||||
<p>Si te encaja, responde a este email o escríbenos por WhatsApp y concertamos la visita. Sin compromiso.</p>
|
preheader: 'Dentro: el render de cómo quedaría y el desglose por partidas (PDF adjunto).',
|
||||||
<p>—<br/>${empresa}</p>`;
|
headline: `Aquí está tu presupuesto, ${primerNombre(opts.nombre)}`,
|
||||||
|
parrafosHtml: [
|
||||||
|
`Hemos preparado el <strong>presupuesto orientativo</strong> de tu reforma. En el PDF adjunto tienes el render de cómo quedaría tu espacio y el desglose por partidas.`,
|
||||||
|
`<em>Es una estimación.</em> El precio definitivo lo confirma ${empresaB} en una <strong>visita gratuita</strong> en tu casa, donde lo mide todo al detalle y lo ajusta. Sin compromiso.`,
|
||||||
|
`¿Dudas con el render o con alguna partida? Respóndenos y te lo explicamos.`,
|
||||||
|
],
|
||||||
|
cta: opts.cta ?? null,
|
||||||
|
footer: `Presupuesto orientativo. El precio final puede variar según la visita técnica. · ${opts.empresa}`,
|
||||||
|
logoCid: logo ? logo.cid : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachments: Array<{
|
||||||
|
filename: string;
|
||||||
|
content?: Buffer;
|
||||||
|
path?: string;
|
||||||
|
cid?: string;
|
||||||
|
contentType?: string;
|
||||||
|
}> = [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }];
|
||||||
|
if (logo) attachments.push(logo);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: env.EMAIL_FROM,
|
from: env.EMAIL_FROM,
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
subject: `Tu presupuesto de reforma con ${opts.empresa} ya está listo`,
|
subject: 'Aquí está tu presupuesto de reforma',
|
||||||
html,
|
html: construirEmailHtml(parts),
|
||||||
attachments: [{ filename: opts.filename, content: opts.pdf, contentType: 'application/pdf' }],
|
text: construirEmailText(parts),
|
||||||
|
attachments,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -65,25 +234,36 @@ export async function enviarEnlaceFormulario(opts: {
|
|||||||
nombre: string;
|
nombre: string;
|
||||||
empresa: string;
|
empresa: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
brand?: EmailBrand;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const transport = getTransport();
|
const transport = getTransport();
|
||||||
if (!transport) return false;
|
if (!transport) return false;
|
||||||
|
|
||||||
const nombre = escapeHtml(opts.nombre.split(' ')[0] ?? opts.nombre);
|
const empresaB = `<strong>${escapeHtml(opts.empresa)}</strong>`;
|
||||||
const empresa = escapeHtml(opts.empresa);
|
const logo = prepararLogo(opts.brand?.logoUrl);
|
||||||
const url = encodeURI(opts.url);
|
const parts: EmailParts = {
|
||||||
const html = `<p>Hola ${nombre},</p>
|
brand: opts.brand ?? BRAND_DEFECTO,
|
||||||
<p>Para preparar tu render y tu presupuesto, ${empresa} necesita ver el espacio. Sube unas fotos de cada zona desde este enlace, cuando te venga bien:</p>
|
empresa: opts.empresa,
|
||||||
<p>👉 <a href="${url}">Subir mis fotos</a></p>
|
preheader: 'Un minuto para subir las fotos de cada zona y seguimos con tu presupuesto.',
|
||||||
<p>Tardas un minuto y puedes añadir las que quieras. En cuanto las tengamos, seguimos con tu presupuesto.</p>
|
headline: `Enséñanos tu espacio, ${primerNombre(opts.nombre)}`,
|
||||||
<p>—<br/>${empresa}</p>`;
|
parrafosHtml: [
|
||||||
|
`Para preparar tu render y tu presupuesto, ${empresaB} necesita ver cómo está ahora tu espacio.`,
|
||||||
|
`Sube unas fotos de cada zona desde el botón de abajo. Tardas un minuto y puedes añadir las que quieras.`,
|
||||||
|
`En cuanto las tengamos, seguimos con tu presupuesto.`,
|
||||||
|
],
|
||||||
|
cta: { url: encodeURI(opts.url), label: 'Subir mis fotos' },
|
||||||
|
footer: opts.empresa,
|
||||||
|
logoCid: logo ? logo.cid : null,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: env.EMAIL_FROM,
|
from: env.EMAIL_FROM,
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
subject: `Sube las fotos de tu reforma para ${opts.empresa}`,
|
subject: 'Sube las fotos de tu reforma',
|
||||||
html,
|
html: construirEmailHtml(parts),
|
||||||
|
text: construirEmailText(parts),
|
||||||
|
attachments: logo ? [logo] : undefined,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -91,3 +271,5 @@ export async function enviarEnlaceFormulario(opts: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { construirEmailHtml, construirEmailText };
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { leads, leadPipelineEventos } from '@/db/schema';
|
|||||||
import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto';
|
import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto';
|
||||||
import { enviarPresupuestoEmail } from '@/lib/email/mailer';
|
import { enviarPresupuestoEmail } from '@/lib/email/mailer';
|
||||||
import { notificarFlujoWhatsapp } from '@/lib/webhooks';
|
import { notificarFlujoWhatsapp } from '@/lib/webhooks';
|
||||||
|
import { resolveTheme } from '@/lib/funnel/themes';
|
||||||
|
import { normalizarTelefonoEs } from '@/lib/voice/retell';
|
||||||
|
|
||||||
export type ResultadoFinalizar = {
|
export type ResultadoFinalizar = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -27,6 +29,15 @@ export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinal
|
|||||||
.set({ pdfUrl: `data:application/pdf;base64,${pdfBase64}`, updatedAt: new Date() })
|
.set({ pdfUrl: `data:application/pdf;base64,${pdfBase64}`, updatedAt: new Date() })
|
||||||
.where(eq(leads.id, leadId));
|
.where(eq(leads.id, leadId));
|
||||||
|
|
||||||
|
// Marca del reformista para el acento del email + CTA de contacto (WhatsApp o email).
|
||||||
|
const theme = resolveTheme(tenant.themePreset, tenant.themeColor);
|
||||||
|
const tel = tenant.telefono ? normalizarTelefonoEs(tenant.telefono) : null;
|
||||||
|
const cta = tel
|
||||||
|
? { url: `https://wa.me/${tel.replace('+', '')}`, label: 'Agendar mi visita gratuita' }
|
||||||
|
: tenant.email
|
||||||
|
? { url: `mailto:${tenant.email}`, label: 'Agendar mi visita gratuita' }
|
||||||
|
: null;
|
||||||
|
|
||||||
const [emailEnviado, whatsappSenal] = await Promise.all([
|
const [emailEnviado, whatsappSenal] = await Promise.all([
|
||||||
enviarPresupuestoEmail({
|
enviarPresupuestoEmail({
|
||||||
to: lead.email,
|
to: lead.email,
|
||||||
@@ -34,6 +45,13 @@ export async function finalizarYEntregar(leadId: string): Promise<ResultadoFinal
|
|||||||
empresa: tenant.nombreEmpresa,
|
empresa: tenant.nombreEmpresa,
|
||||||
pdf: buffer,
|
pdf: buffer,
|
||||||
filename,
|
filename,
|
||||||
|
brand: {
|
||||||
|
primary: theme.primary,
|
||||||
|
primaryDark: theme.primaryDark,
|
||||||
|
contrast: theme.contrast,
|
||||||
|
logoUrl: tenant.logoUrl,
|
||||||
|
},
|
||||||
|
cta,
|
||||||
}),
|
}),
|
||||||
notificarFlujoWhatsapp({
|
notificarFlujoWhatsapp({
|
||||||
leadId,
|
leadId,
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export async function procesarLead(leadId: string): Promise<void> {
|
|||||||
m2Suelo: lead.m2Suelo ?? null,
|
m2Suelo: lead.m2Suelo ?? null,
|
||||||
alturaTecho: lead.alturaTecho ?? null,
|
alturaTecho: lead.alturaTecho ?? null,
|
||||||
provincia: lead.provincia ?? null,
|
provincia: lead.provincia ?? null,
|
||||||
|
anteriorA2000: lead.anteriorA2000,
|
||||||
|
cambioDistribucion: lead.cambioDistribucion,
|
||||||
});
|
});
|
||||||
const result = applyPreferences(computeBudget(inputs, config, catalog), prefs);
|
const result = applyPreferences(computeBudget(inputs, config, catalog), prefs);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ interface LeadInputsSource {
|
|||||||
m2Suelo: number | null;
|
m2Suelo: number | null;
|
||||||
alturaTecho: number | null;
|
alturaTecho: number | null;
|
||||||
provincia: string | null;
|
provincia: string | null;
|
||||||
|
anteriorA2000?: boolean;
|
||||||
|
cambioDistribucion?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeIntoBudgetInputs(
|
export function mergeIntoBudgetInputs(
|
||||||
@@ -20,6 +22,8 @@ export function mergeIntoBudgetInputs(
|
|||||||
estructural: prefs.estructural,
|
estructural: prefs.estructural,
|
||||||
provincia: lead.provincia,
|
provincia: lead.provincia,
|
||||||
materialSelections: prefs.materialSelections,
|
materialSelections: prefs.materialSelections,
|
||||||
|
anteriorA2000: lead.anteriorA2000 ?? false,
|
||||||
|
cambioDistribucion: lead.cambioDistribucion ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { BudgetInputs, CatalogItem, PricingConfig } from '@/budget/types';
|
|||||||
const config: PricingConfig = {
|
const config: PricingConfig = {
|
||||||
alturaTechoDefault: 2.5,
|
alturaTechoDefault: 2.5,
|
||||||
factorZona: { Madrid: 1.1 },
|
factorZona: { Madrid: 1.1 },
|
||||||
manoObra: { demolicion: 1500, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 },
|
manoObra: { demolicion: 1500, impermeabilizacion: 0, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const catalog: CatalogItem[] = [
|
const catalog: CatalogItem[] = [
|
||||||
@@ -102,3 +102,50 @@ describe('computeBudget', () => {
|
|||||||
expect(r.total).toBeGreaterThan(0);
|
expect(r.total).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Config con impermeabilización y extras fijos activos, sin factor de zona (total = subtotal).
|
||||||
|
const configFull: PricingConfig = {
|
||||||
|
alturaTechoDefault: 2.5,
|
||||||
|
factorZona: {},
|
||||||
|
manoObra: { demolicion: 1500, impermeabilizacion: 2000, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 },
|
||||||
|
extras: { tuberias: 100000, boletin: 15000, distribucion: 80000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('computeBudget — impermeabilización y extras fijos', () => {
|
||||||
|
it('añade impermeabilización en zonas húmedas (baño) proporcional al suelo', () => {
|
||||||
|
const r = computeBudget(inputs({ tipoReforma: 'bano', m2Suelo: 5, provincia: null }), configFull, catalog);
|
||||||
|
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
|
||||||
|
expect(byKey.impermeabilizacion).toBe(10000); // 5 * 2000
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no añade impermeabilización en zonas secas (salón)', () => {
|
||||||
|
const r = computeBudget(inputs({ tipoReforma: 'salon', provincia: null }), configFull, catalog);
|
||||||
|
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
|
||||||
|
expect(byKey.impermeabilizacion).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aplica siempre el boletín eléctrico como extra fijo', () => {
|
||||||
|
const r = computeBudget(inputs({ provincia: null }), configFull, catalog);
|
||||||
|
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
|
||||||
|
expect(byKey.extras_fijos).toBe(15000); // solo boletín
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suma tuberías y distribución según los inputs del piso', () => {
|
||||||
|
const r = computeBudget(
|
||||||
|
inputs({ provincia: null, anteriorA2000: true, cambioDistribucion: true }),
|
||||||
|
configFull,
|
||||||
|
catalog,
|
||||||
|
);
|
||||||
|
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
|
||||||
|
expect(byKey.extras_fijos).toBe(195000); // 15000 + 100000 + 80000
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la intensidad por tipo reduce la fontanería de un integral frente a una cocina', () => {
|
||||||
|
const cocina = computeBudget(inputs({ tipoReforma: 'cocina', m2Suelo: 16, provincia: null }), configFull, catalog);
|
||||||
|
const integral = computeBudget(inputs({ tipoReforma: 'integral', m2Suelo: 16, provincia: null }), configFull, catalog);
|
||||||
|
const fontCocina = cocina.partidas.find((p) => p.key === 'fontaneria')?.importe ?? 0;
|
||||||
|
const fontIntegral = integral.partidas.find((p) => p.key === 'fontaneria')?.importe ?? 0;
|
||||||
|
expect(fontCocina).toBe(19200); // 16 * 1200 * 1.0
|
||||||
|
expect(fontIntegral).toBe(8640); // 16 * 1200 * 0.45
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { BudgetInputs, PricingConfig } from '@/budget/types';
|
|||||||
const config: PricingConfig = {
|
const config: PricingConfig = {
|
||||||
alturaTechoDefault: 2.5,
|
alturaTechoDefault: 2.5,
|
||||||
factorZona: {},
|
factorZona: {},
|
||||||
manoObra: { demolicion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0 },
|
manoObra: { demolicion: 0, impermeabilizacion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
function inputs(partial: Partial<BudgetInputs>): BudgetInputs {
|
function inputs(partial: Partial<BudgetInputs>): BudgetInputs {
|
||||||
|
|||||||
Reference in New Issue
Block a user