Compare commits

..

15 Commits

Author SHA1 Message Date
Carlos Narro
6be00e3eb5 fix: validate numeric pricing inputs and drop unused import
Guard euro/altura inputs in precios actions so empty or non-numeric
form values return a Spanish error instead of writing NaN and throwing
a 500. Remove the now-unused formatEuros import flagged by ESLint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:46:52 +02:00
Carlos Narro
588aa4dc1c feat: wire computeBudget into recalcularPresupuesto and show desglose
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:41:40 +02:00
Carlos Narro
4106d58614 feat: add pricing panel with catalog CRUD and CSV import 2026-05-30 12:36:31 +02:00
Carlos Narro
c00c571549 feat: add queries mapping pricing config and catalog to engine types 2026-05-30 12:33:11 +02:00
Carlos Narro
892c257182 feat: migrate and seed pricing config + demo catalog 2026-05-30 12:31:58 +02:00
Carlos Narro
afef9f2cb0 feat: add pricing_config, catalog_items and budget input fields to schema 2026-05-30 12:30:03 +02:00
Carlos Narro
e6f8b47205 fix: correct factorZona zero handling and confidence for unresolved selections 2026-05-30 12:28:10 +02:00
Carlos Narro
58d3f62a76 feat: add catalog CSV parser with per-row validation 2026-05-30 12:24:47 +02:00
Carlos Narro
896c7ac89b feat: implement computeBudget with partidas, zona factor, licencia and range 2026-05-30 12:22:42 +02:00
Carlos Narro
61e0f5dbe5 feat: resolve unit price from catalog with selection override
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:20:10 +02:00
Carlos Narro
b27b68908c feat: derive cantidades from minimal measurements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:18:48 +02:00
Carlos Narro
9b14dbfac5 feat: add budget domain types and partida labels 2026-05-30 12:17:04 +02:00
Carlos Narro
515e9fd7a2 chore: set up vitest and add zod 2026-05-30 12:15:26 +02:00
Carlos Narro
75de172900 docs: add motor de presupuesto implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:12:07 +02:00
Carlos Narro
bd07586b03 Add motor de presupuesto design spec
Diseño validado del motor de presupuesto: modelo híbrido partidas←precios
unitarios, medidas mínimas (m² suelo + supuestos), calidad B/M/P + catálogo
importable por CSV, y progressive disclosure de personalización en el funnel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:27:06 +02:00
28 changed files with 5215 additions and 8 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
# Motor de presupuesto Reformix — Diseño
> Fecha: 2026-05-30 · Estado: aprobado (brainstorming) · Owner dominio: Goyo · Coord: Carlos
> Alcance: F2 (en sprint actual). El configurador multi-tenant real sigue siendo F1.5.
## Objetivo
Producir un presupuesto orientativo desglosado por partidas a partir de datos que
escalan "de menos a más": con el mínimo (tipo de reforma + calidad) ya sale un número,
y cada etapa del funnel (medidas, material exacto, llamada) lo afina. El reformista
define los precios en el panel; el catálogo se puede sembrar y actualizar por CSV.
Requisitos cubiertos: RF-C-21 (desglose por partidas + factor zona), RF-C-22 (licencia
si hay cambios estructurales), RF-D-07 (tabla de precios editable), RF-B-07 (DIN-A4 como
input de medidas, *fallback* stubbeable), RF-B-09 (disclaimer orientativo),
RNF-MAINT-01 (≥70% cobertura en `src/budget/*`).
## Decisiones de diseño (validadas con el usuario)
1. **Modelo híbrido partidas ← precios unitarios.** El reformista configura precios
unitarios (€/m² suelo por calidad, €/m² pared, €/m² pintura, €/ml mobiliario, mano
de obra). El motor calcula cantidades desde las medidas y agrupa el resultado en las
partidas de RF-C-21.
2. **Medidas mínimas = m² de suelo + supuestos.** El resto se deriva: perímetro ≈ 4·√(m²),
m² pared = perímetro × altura (2,5 m por defecto). Si no hay m², mediana por tipo.
El refinamiento con dimensiones reales (largo×ancho×alto) o DIN-A4 es posterior/opcional.
3. **Calidad = columna de precio por material (B/M/P)** más un **catálogo de materiales**
con precio e identidad propia, importable por CSV. El cliente elige una calidad global
por defecto; puede personalizar material exacto si quiere.
4. **Progressive disclosure.** Se fomenta lo básico (solo calidad). La personalización
(material exacto del catálogo) aparece sutil y opcional en el funnel, y el agente la
afina en la llamada. El material elegido alimenta el prompt del render para que la
imagen refleje exactamente lo presupuestado.
## Arquitectura
```
PANEL (CRUD) pricing_config + catalog_items (por tenant)
reformista · precios unitarios B/M/P por material
+ CSV import · mano de obra, factor zona, partidas
│ (lee de DB)
FUNNEL (cliente)
inputs por etapa ─► computeBudget(config, catalog, inputs) ─► BudgetResult
· m² suelo, calidad [ función PURA en src/budget/ ] · partidas[]
· (opc.) material exacto · subtotal, total
· (llamada) estructural · rango + confianza
· materiales→render
```
- **`src/budget/` (núcleo puro):** tipos, `computeBudget()`, derivación de cantidades,
agrupación en partidas, partida condicional de licencia, cálculo de rango+confianza.
Sin imports de DB ni de red. Es el módulo con cobertura ≥70% (RNF-MAINT-01).
- **DB (Drizzle):** extiende el schema existente (`src/db/schema.ts`).
- **Panel:** CRUD sobre config/catálogo + importador CSV. Tenant único "Reformas Ejemplo".
- **Funnel:** recoge inputs mínimos, llama al engine, persiste snapshot por etapa.
## Flujo de cálculo `computeBudget(config, catalog, inputs)`
1. **Cantidades** (con degradación):
- `m² suelo` aportado; si no, mediana por tipo (cocina 10, baño 5, salón 20, integral 70).
- `perímetro ≈ 4·√(m² suelo)`; `m² pared = perímetro × alturaTecho` (default 2,5 m).
- `ml mobiliario ≈ perímetro × factor_tipo` (solo cocina/baño).
2. **Precio unitario** por material: ítem exacto elegido del catálogo si existe; si no,
ítem `esDefault` de la calidad global.
3. **Partidas** (RF-C-21): cada partida = Σ(cantidad × precio unitario material) + mano de
obra. Categorías: demolición, alicatado, fontanería, electricidad, carpintería, mano de
obra, extras.
4. **Factor zona:** multiplicador por provincia (configurable) sobre el subtotal.
5. **Licencia** (RF-C-22): si `inputs.estructural = true`, partida "Licencia + Proyecto
técnico" con rango 3001.500 €.
6. **Rango + confianza:** total como `{ min, max, confianza }`. Menos datos → banda más
ancha (≈ ±25% solo con calidad; ≈ ±10% tras llamada con material exacto + estructural
confirmado).
## Modelo de datos (extensión Drizzle)
```
pricing_config (1 por tenant)
tenantId, alturaTechoDefault, factorZona (jsonb provincia→mult),
manoObra (jsonb partida→€), updatedAt
catalog_items (N por tenant)
id, tenantId, categoria (suelo|pared|pintura|mobiliario|...),
nombre, calidad (basica|media|premium), precioUnit (cents),
unidad (m2|ml|ud), descriptorRender (text), esDefault (bool), sku
leads (campos nuevos)
m2Suelo, alturaTecho, calidadGlobal, estructural,
materialSelections (jsonb categoria→catalogItemId),
desgloseSnapshot (jsonb: partidas+rango por pipeline_stage)
```
- Dinero en **enteros (cents)**, consistente con el schema actual.
- `desgloseSnapshot` guarda el resultado **en cada `pipeline_stage`** → permite analizar
cómo evoluciona la estimación lead a lead.
- `descriptorRender` de cada material se inyecta en el prompt del render.
## Panel del reformista (`/panel/precios`)
- **Catálogo editable:** tabla de `catalog_items` por categoría, precio por calidad inline;
crear/editar/borrar; marcar `esDefault` por calidad.
- **Importar CSV:** subida → parse con zod → preview filas válidas/erróneas → confirmar.
Cabeceras: `categoria,nombre,calidad,precio,unidad,descriptor_render,sku`. *Upsert* por
`sku`. Si hay errores de validación, se escriben **cero** filas.
- **Config general:** factor zona por provincia, mano de obra por partida, altura techo.
## Funnel (cliente) — progressive disclosure
- Por defecto: **tipo de reforma + calidad (B/M/P)** y, opcional, m² de suelo → estimación
+ render genérico de la calidad.
- Afordance sutil **"Personalizar materiales"** (colapsado): galería del catálogo para
elegir ítems exactos. Quien no la toca, usa el default.
- La **llamada** enriquece inputs (material exacto, estructural, medidas finas) → recálculo
+ render exacto.
## Seed (demo 11-jun-2026)
Sembrar `pricing_config` + un **catálogo demo** (suelos, alicatados, pinturas, mobiliario
en B/M/P con `descriptorRender`) para que la demo funcione sin cargar CSV.
## Fuera de alcance (ahora)
- Recálculo retroactivo de presupuestos ya guardados (los snapshots no se tocan).
- Calidad por elemento mezclada como input por defecto (refinamiento F1.5; el catálogo ya
lo permite a nivel de selección manual).
- Extracción real de medidas por visión/DIN-A4: se deja **stub** (`has_din_a4` flag +
hook de medidas) y se usa la degradación por medianas mientras tanto.
- Multi-tenant real (F1.5): todo opera sobre "Reformas Ejemplo".
- Dimensiones largo×ancho×alto como input mínimo (queda como refinamiento opcional).
## Verificación
- Tests unitarios de `computeBudget` con inputs conocidos: desglose ±1 € vs cálculo manual
(RF-C-21), partida de licencia presente con `estructural=true` (RF-C-22), degradación sin
m², estrechamiento del rango al añadir datos. Cobertura `src/budget/*` ≥70% (RNF-MAINT-01).
- Test de parser CSV: filas válidas/erróneas, upsert por sku, cero escrituras si hay error.

View File

@@ -0,0 +1,36 @@
CREATE TYPE "public"."calidad" AS ENUM('basica', 'media', 'premium');--> statement-breakpoint
CREATE TYPE "public"."categoria_material" AS ENUM('suelo', 'pared', 'pintura', 'mobiliario');--> statement-breakpoint
CREATE TYPE "public"."unidad_medida" AS ENUM('m2', 'ml', 'ud');--> statement-breakpoint
CREATE TABLE "catalog_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"categoria" "categoria_material" NOT NULL,
"nombre" text NOT NULL,
"calidad" "calidad" NOT NULL,
"precio_unit" integer NOT NULL,
"unidad" "unidad_medida" NOT NULL,
"descriptor_render" text DEFAULT '' NOT NULL,
"es_default" boolean DEFAULT false NOT NULL,
"sku" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pricing_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"altura_techo_default" double precision DEFAULT 2.5 NOT NULL,
"factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL,
"mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id")
);
--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "m2_suelo" double precision;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "altura_techo" double precision;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "calidad_global" "calidad";--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "estructural" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "material_selections" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "desglose_snapshot" jsonb;--> statement-breakpoint
ALTER TABLE "catalog_items" ADD CONSTRAINT "catalog_items_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pricing_config" ADD CONSTRAINT "pricing_config_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "catalog_tenant_idx" ON "catalog_items" USING btree ("tenant_id");--> statement-breakpoint
CREATE UNIQUE INDEX "catalog_tenant_sku_idx" ON "catalog_items" USING btree ("tenant_id","sku");

View File

@@ -0,0 +1,834 @@
{
"id": "57e8d006-18f6-4aba-a61a-02d155a80bbc",
"prevId": "66acce06-f292-49db-adc1-fa9cfcc7d2a9",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.catalog_items": {
"name": "catalog_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"categoria": {
"name": "categoria",
"type": "categoria_material",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"nombre": {
"name": "nombre",
"type": "text",
"primaryKey": false,
"notNull": true
},
"calidad": {
"name": "calidad",
"type": "calidad",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"precio_unit": {
"name": "precio_unit",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"unidad": {
"name": "unidad",
"type": "unidad_medida",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"descriptor_render": {
"name": "descriptor_render",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"es_default": {
"name": "es_default",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"sku": {
"name": "sku",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"catalog_tenant_idx": {
"name": "catalog_tenant_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"catalog_tenant_sku_idx": {
"name": "catalog_tenant_sku_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"catalog_items_tenant_id_tenants_id_fk": {
"name": "catalog_items_tenant_id_tenants_id_fk",
"tableFrom": "catalog_items",
"tableTo": "tenants",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_estado_history": {
"name": "lead_estado_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"estado": {
"name": "estado",
"type": "lead_estado",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"changed_at": {
"name": "changed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"changed_by": {
"name": "changed_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"lead_estado_history_lead_id_leads_id_fk": {
"name": "lead_estado_history_lead_id_leads_id_fk",
"tableFrom": "lead_estado_history",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_fotos": {
"name": "lead_fotos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"orden": {
"name": "orden",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"lead_fotos_lead_id_leads_id_fk": {
"name": "lead_fotos_lead_id_leads_id_fk",
"tableFrom": "lead_fotos",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_pipeline_eventos": {
"name": "lead_pipeline_eventos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"stage": {
"name": "stage",
"type": "pipeline_stage",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"occurred_at": {
"name": "occurred_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"lead_pipeline_eventos_lead_id_leads_id_fk": {
"name": "lead_pipeline_eventos_lead_id_leads_id_fk",
"tableFrom": "lead_pipeline_eventos",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.leads": {
"name": "leads",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"nombre": {
"name": "nombre",
"type": "text",
"primaryKey": false,
"notNull": true
},
"telefono": {
"name": "telefono",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provincia": {
"name": "provincia",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tipo_reforma": {
"name": "tipo_reforma",
"type": "tipo_reforma",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"consent_privacidad": {
"name": "consent_privacidad",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"consent_contratacion": {
"name": "consent_contratacion",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"pipeline_stage": {
"name": "pipeline_stage",
"type": "pipeline_stage",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'form_completado'"
},
"estado": {
"name": "estado",
"type": "lead_estado",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'nuevo'"
},
"presupuesto_estimado": {
"name": "presupuesto_estimado",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"transcripcion": {
"name": "transcripcion",
"type": "text",
"primaryKey": false,
"notNull": false
},
"entidades": {
"name": "entidades",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"render_url": {
"name": "render_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pdf_url": {
"name": "pdf_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"audio_url": {
"name": "audio_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"notas": {
"name": "notas",
"type": "text",
"primaryKey": false,
"notNull": false
},
"m2_suelo": {
"name": "m2_suelo",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"altura_techo": {
"name": "altura_techo",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"calidad_global": {
"name": "calidad_global",
"type": "calidad",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"estructural": {
"name": "estructural",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"material_selections": {
"name": "material_selections",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"desglose_snapshot": {
"name": "desglose_snapshot",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"leads_tenant_created_idx": {
"name": "leads_tenant_created_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"leads_estado_idx": {
"name": "leads_estado_idx",
"columns": [
{
"expression": "estado",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"leads_tenant_id_tenants_id_fk": {
"name": "leads_tenant_id_tenants_id_fk",
"tableFrom": "leads",
"tableTo": "tenants",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.precision_history": {
"name": "precision_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"estimated": {
"name": "estimated",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"final": {
"name": "final",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"delta_pct": {
"name": "delta_pct",
"type": "numeric(6, 2)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"precision_history_lead_id_leads_id_fk": {
"name": "precision_history_lead_id_leads_id_fk",
"tableFrom": "precision_history",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pricing_config": {
"name": "pricing_config",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"altura_techo_default": {
"name": "altura_techo_default",
"type": "double precision",
"primaryKey": false,
"notNull": true,
"default": 2.5
},
"factor_zona": {
"name": "factor_zona",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"mano_obra": {
"name": "mano_obra",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"pricing_config_tenant_id_tenants_id_fk": {
"name": "pricing_config_tenant_id_tenants_id_fk",
"tableFrom": "pricing_config",
"tableTo": "tenants",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pricing_config_tenant_id_unique": {
"name": "pricing_config_tenant_id_unique",
"nullsNotDistinct": false,
"columns": [
"tenant_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenants": {
"name": "tenants",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"nombre_empresa": {
"name": "nombre_empresa",
"type": "text",
"primaryKey": false,
"notNull": true
},
"logo_url": {
"name": "logo_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provincia": {
"name": "provincia",
"type": "text",
"primaryKey": false,
"notNull": false
},
"whatsapp_business": {
"name": "whatsapp_business",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tenants_slug_unique": {
"name": "tenants_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.calidad": {
"name": "calidad",
"schema": "public",
"values": [
"basica",
"media",
"premium"
]
},
"public.categoria_material": {
"name": "categoria_material",
"schema": "public",
"values": [
"suelo",
"pared",
"pintura",
"mobiliario"
]
},
"public.lead_estado": {
"name": "lead_estado",
"schema": "public",
"values": [
"nuevo",
"contactado",
"visita_agendada",
"presupuesto_enviado",
"ganado",
"perdido"
]
},
"public.pipeline_stage": {
"name": "pipeline_stage",
"schema": "public",
"values": [
"form_completado",
"fotos_subidas",
"prellamada_enviada",
"llamada_completada",
"render_generado",
"presupuesto_generado",
"whatsapp_entregado"
]
},
"public.tipo_reforma": {
"name": "tipo_reforma",
"schema": "public",
"values": [
"cocina",
"bano",
"salon",
"comedor",
"integral",
"otro"
]
},
"public.unidad_medida": {
"name": "unidad_medida",
"schema": "public",
"values": [
"m2",
"ml",
"ud"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1780056789929, "when": 1780056789929,
"tag": "0000_motionless_jackpot", "tag": "0000_motionless_jackpot",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1780137082579,
"tag": "0001_bored_preak",
"breakpoints": true
} }
] ]
} }

1424
mvp/b2c/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,10 @@
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts" "db:seed": "tsx src/db/seed.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
@@ -21,17 +24,20 @@
"postgres": "^3.4.9", "postgres": "^3.4.9",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"tailwindcss": "^4.3.0" "tailwindcss": "^4.3.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.7",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.6", "eslint-config-next": "16.2.6",
"tsx": "^4.22.3", "tsx": "^4.22.3",
"typescript": "^5" "typescript": "^5",
"vitest": "^4.1.7"
} }
} }

View File

@@ -10,6 +10,8 @@ import {
formatEuros, formatEuros,
formatFecha, formatFecha,
} from '@/lib/funnel'; } from '@/lib/funnel';
import { recalcularPresupuesto } from '../actions';
import type { BudgetResult } from '@/budget/types';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -30,6 +32,9 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
const { lead, fotos, eventos, precision } = data; const { lead, fotos, eventos, precision } = data;
const reachedStages = new Set(eventos.map((e) => e.stage)); const reachedStages = new Set(eventos.map((e) => e.stage));
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Link href="/panel" className="text-sm text-gray-500 hover:text-black w-fit"> <Link href="/panel" className="text-sm text-gray-500 hover:text-black w-fit">
@@ -208,6 +213,98 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
</div> </div>
</Section> </Section>
)} )}
{/* Presupuesto desglosado */}
<Section title="Presupuesto desglosado">
<form action={recalcularPresupuesto.bind(null, lead.id)}>
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
>
Recalcular presupuesto
</button>
</form>
{desglose ? (
<div className="flex flex-col gap-4 mt-2">
{/* Partidas */}
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-400 uppercase tracking-wide border-b border-gray-100">
<th className="pb-2 font-semibold">Partida</th>
<th className="pb-2 font-semibold text-right">Importe</th>
</tr>
</thead>
<tbody>
{desglose.partidas.map((partida) => (
<tr key={partida.key} className="border-b border-gray-50">
<td className="py-1.5 text-gray-700">{partida.label}</td>
<td className="py-1.5 text-right text-black font-medium">
{formatEuros(partida.importe)}
</td>
</tr>
))}
</tbody>
</table>
{/* Subtotal, factor zona, total */}
<div className="flex flex-col gap-1 text-sm border-t border-gray-200 pt-3">
<div className="flex justify-between">
<span className="text-gray-500">Subtotal</span>
<span className="text-black font-medium">{formatEuros(desglose.subtotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Factor de zona</span>
<span className="text-black font-medium">×{desglose.factorZona.toFixed(2)}</span>
</div>
<div className="flex justify-between mt-1 pt-2 border-t border-gray-200">
<span className="text-black font-bold">Total estimado</span>
<span className="text-black font-bold text-lg">{formatEuros(desglose.total)}</span>
</div>
</div>
{/* Rango */}
<div className="flex justify-between text-sm">
<span className="text-gray-500">Rango orientativo</span>
<span className="text-black font-medium">
{formatEuros(desglose.rango.min)} {formatEuros(desglose.rango.max)}
</span>
</div>
{/* Confianza */}
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-500">Confianza del cálculo</span>
<span
className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
desglose.confianza === 'alta'
? 'bg-green-100 text-green-700'
: desglose.confianza === 'media'
? 'bg-amber-100 text-amber-700'
: 'bg-red-100 text-red-700'
}`}
>
{desglose.confianza}
</span>
</div>
{/* Avisos */}
{desglose.avisos.length > 0 && (
<ul className="text-xs text-amber-700 bg-amber-50 rounded-lg p-3 flex flex-col gap-1">
{desglose.avisos.map((aviso, i) => (
<li key={i}> {aviso}</li>
))}
</ul>
)}
{/* Disclaimer RF-B-09 */}
<p className="text-xs text-gray-400">
Presupuesto orientativo. El precio final puede variar según la visita técnica.
</p>
</div>
) : (
<p className="text-sm text-gray-400">Aún no se ha calculado el presupuesto.</p>
)}
</Section>
</div> </div>
); );
} }

View File

@@ -3,8 +3,11 @@
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { db } from '@/db'; import { db } from '@/db';
import { leads, leadEstadoHistory, precisionHistory, tenants } from '@/db/schema'; import { leads, leadEstadoHistory, leadPipelineEventos, precisionHistory, tenants } from '@/db/schema';
import { TENANT_SLUG } from '@/lib/funnel'; import { TENANT_SLUG } from '@/lib/funnel';
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
import { computeBudget } from '@/budget';
import type { BudgetInputs } from '@/budget/types';
async function getTenantId(): Promise<string> { async function getTenantId(): Promise<string> {
const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1); const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1);
@@ -60,3 +63,45 @@ export async function marcarGanado(leadId: string, precioFinalEuros: number) {
revalidatePath('/panel'); revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`); revalidatePath(`/panel/${leadId}`);
} }
export async function recalcularPresupuesto(leadId: string) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Lead no encontrado.');
const [config, catalog] = await Promise.all([getPricingConfig(), getCatalog()]);
const inputs: BudgetInputs = {
tipoReforma: lead.tipoReforma ?? 'otro',
m2Suelo: lead.m2Suelo ?? null,
alturaTecho: lead.alturaTecho ?? null,
calidadGlobal: lead.calidadGlobal ?? 'media',
estructural: lead.estructural,
provincia: lead.provincia ?? null,
materialSelections: (lead.materialSelections as Record<string, string>) ?? {},
};
const result = computeBudget(inputs, config, catalog);
await db
.update(leads)
.set({
presupuestoEstimado: result.total,
desgloseSnapshot: { stage: lead.pipelineStage, result },
updatedAt: new Date(),
})
.where(eq(leads.id, leadId));
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'presupuesto_generado',
metadata: { total: result.total, confianza: result.confianza },
});
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}

View File

@@ -19,7 +19,14 @@ export default function PanelLayout({ children }: { children: React.ReactNode })
<span className="text-gray-300">/</span> <span className="text-gray-300">/</span>
<span className="text-sm font-medium text-gray-600">Reformas Ejemplo</span> <span className="text-sm font-medium text-gray-600">Reformas Ejemplo</span>
</Link> </Link>
<span className="text-xs font-medium text-gray-400">Panel del reformista</span> <nav className="flex items-center gap-4 text-xs font-medium">
<Link href="/panel" className="text-gray-500 hover:text-black">
Leads
</Link>
<Link href="/panel/precios" className="text-gray-500 hover:text-black">
Precios
</Link>
</nav>
</div> </div>
</header> </header>
<main className="max-w-6xl mx-auto px-6 py-8">{children}</main> <main className="max-w-6xl mx-auto px-6 py-8">{children}</main>

View File

@@ -0,0 +1,105 @@
'use server';
import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { catalogItems, pricingConfig } from '@/db/schema';
import { getTenantId } from '@/db/pricing-queries';
import { parseCatalogCsv } from '@/budget/csv';
// Valida un importe en euros del formulario y lo convierte a céntimos.
// Lanza un error en español si el valor no es un número finito >= 0.
function eurosToCents(raw: FormDataEntryValue | null, campo: string): number {
const euros = Number(raw);
if (!Number.isFinite(euros) || euros < 0) {
throw new Error(`El valor de "${campo}" debe ser un número mayor o igual que 0.`);
}
return Math.round(euros * 100);
}
function parsePositive(raw: FormDataEntryValue | null, campo: string): number {
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) {
throw new Error(`El valor de "${campo}" debe ser un número mayor que 0.`);
}
return n;
}
export async function crearMaterial(formData: FormData) {
const tenantId = await getTenantId();
await db.insert(catalogItems).values({
tenantId,
categoria: formData.get('categoria') as 'suelo' | 'pared' | 'pintura' | 'mobiliario',
nombre: String(formData.get('nombre') ?? ''),
calidad: formData.get('calidad') as 'basica' | 'media' | 'premium',
precioUnit: eurosToCents(formData.get('precioEuros'), 'precio'),
unidad: formData.get('unidad') as 'm2' | 'ml' | 'ud',
descriptorRender: String(formData.get('descriptorRender') ?? ''),
esDefault: formData.get('esDefault') === 'on',
sku: String(formData.get('sku') ?? ''),
});
revalidatePath('/panel/precios');
}
export async function actualizarPrecio(formData: FormData) {
const tenantId = await getTenantId();
const id = String(formData.get('id') ?? '');
await db
.update(catalogItems)
.set({ precioUnit: eurosToCents(formData.get('precioEuros'), 'precio') })
.where(and(eq(catalogItems.id, id), eq(catalogItems.tenantId, tenantId)));
revalidatePath('/panel/precios');
}
export async function borrarMaterial(formData: FormData) {
const tenantId = await getTenantId();
const id = String(formData.get('id') ?? '');
await db.delete(catalogItems).where(and(eq(catalogItems.id, id), eq(catalogItems.tenantId, tenantId)));
revalidatePath('/panel/precios');
}
export async function actualizarConfig(formData: FormData) {
const tenantId = await getTenantId();
await db
.update(pricingConfig)
.set({
alturaTechoDefault: parsePositive(formData.get('alturaTechoDefault'), 'altura de techo'),
manoObra: {
demolicion: eurosToCents(formData.get('mo_demolicion'), 'demolición'),
fontaneria: eurosToCents(formData.get('mo_fontaneria'), 'fontanería'),
electricidad: eurosToCents(formData.get('mo_electricidad'), 'electricidad'),
mano_de_obra: eurosToCents(formData.get('mo_mano_de_obra'), 'mano de obra'),
},
updatedAt: new Date(),
})
.where(eq(pricingConfig.tenantId, tenantId));
revalidatePath('/panel/precios');
}
export type ImportResult = { ok: boolean; inserted: number; errors: { line: number; message: string }[] };
export async function importarCatalogoCsv(_prev: ImportResult | null, formData: FormData): Promise<ImportResult> {
const tenantId = await getTenantId();
const csv = String(formData.get('csv') ?? '');
const { rows, errors } = parseCatalogCsv(csv);
if (errors.length > 0) return { ok: false, inserted: 0, errors };
for (const r of rows) {
await db
.insert(catalogItems)
.values({ tenantId, ...r })
.onConflictDoUpdate({
target: [catalogItems.tenantId, catalogItems.sku],
set: {
categoria: r.categoria,
nombre: r.nombre,
calidad: r.calidad,
precioUnit: r.precioUnit,
unidad: r.unidad,
descriptorRender: r.descriptorRender,
},
});
}
revalidatePath('/panel/precios');
return { ok: true, inserted: rows.length, errors: [] };
}

View File

@@ -0,0 +1,147 @@
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
import {
crearMaterial,
actualizarPrecio,
borrarMaterial,
actualizarConfig,
importarCatalogoCsv,
} from './actions';
export const dynamic = 'force-dynamic';
const CATEGORIAS = ['suelo', 'pared', 'pintura', 'mobiliario'] as const;
const CATEGORIA_LABEL: Record<(typeof CATEGORIAS)[number], string> = {
suelo: 'Suelos',
pared: 'Paredes / alicatado',
pintura: 'Pinturas',
mobiliario: 'Mobiliario',
};
export default async function PreciosPage() {
const [config, catalog] = await Promise.all([getPricingConfig(), getCatalog()]);
return (
<div className="space-y-10">
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-black">Tabla de precios</h1>
<p className="text-sm text-gray-500 mt-1">
Define los precios unitarios y la mano de obra. El motor calcula el presupuesto a
partir de estos valores y las medidas del lead.
</p>
</div>
{/* Config general */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-4">Configuración general</h2>
<form action={actualizarConfig} className="grid grid-cols-2 md:grid-cols-5 gap-4">
<label className="text-sm">
<span className="block text-gray-500 mb-1">Altura techo (m)</span>
<input
name="alturaTechoDefault"
type="number"
step="0.1"
defaultValue={config.alturaTechoDefault}
className="w-full border border-gray-300 rounded-lg px-2 py-1"
/>
</label>
{(['demolicion', 'fontaneria', 'electricidad', 'mano_de_obra'] as const).map((k) => (
<label key={k} className="text-sm">
<span className="block text-gray-500 mb-1">M.O. {k} (/m²)</span>
<input
name={`mo_${k}`}
type="number"
step="0.01"
defaultValue={(config.manoObra[k] ?? 0) / 100}
className="w-full border border-gray-300 rounded-lg px-2 py-1"
/>
</label>
))}
<button className="col-span-2 md:col-span-5 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar configuración
</button>
</form>
</section>
{/* Catálogo por categoría */}
{CATEGORIAS.map((categoria) => {
const items = catalog.filter((c) => c.categoria === categoria);
return (
<section key={categoria} className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-4">{CATEGORIA_LABEL[categoria]}</h2>
<div className="space-y-2">
{items.length === 0 && <p className="text-sm text-gray-400">Sin materiales.</p>}
{items.map((item) => (
<div key={item.id} className="flex items-center gap-3 text-sm border-b border-gray-100 pb-2">
<span className="w-48 font-medium text-black">{item.nombre}</span>
<span className="w-20 text-gray-500 capitalize">{item.calidad}</span>
<span className="w-16 text-gray-400">{item.unidad}</span>
{item.esDefault && (
<span className="text-xs bg-green-100 text-green-700 rounded px-1.5 py-0.5">default</span>
)}
<form action={actualizarPrecio} className="flex items-center gap-2 ml-auto">
<input type="hidden" name="id" value={item.id} />
<input
name="precioEuros"
type="number"
step="0.01"
defaultValue={item.precioUnit / 100}
className="w-24 border border-gray-300 rounded-lg px-2 py-1 text-right"
/>
<span className="text-gray-400"></span>
<button className="text-xs text-blue-600 hover:underline">Guardar</button>
</form>
<form action={borrarMaterial}>
<input type="hidden" name="id" value={item.id} />
<button className="text-xs text-red-500 hover:underline">Borrar</button>
</form>
</div>
))}
</div>
<form action={crearMaterial} className="mt-4 flex flex-wrap items-end gap-2 text-sm">
<input type="hidden" name="categoria" value={categoria} />
<input name="nombre" placeholder="Nombre" required className="border border-gray-300 rounded-lg px-2 py-1" />
<select name="calidad" className="border border-gray-300 rounded-lg px-2 py-1">
<option value="basica">Básica</option>
<option value="media">Media</option>
<option value="premium">Premium</option>
</select>
<input name="precioEuros" type="number" step="0.01" placeholder="€" required className="w-24 border border-gray-300 rounded-lg px-2 py-1" />
<select name="unidad" className="border border-gray-300 rounded-lg px-2 py-1">
<option value="m2">m²</option>
<option value="ml">ml</option>
<option value="ud">ud</option>
</select>
<input name="descriptorRender" placeholder="Descriptor render" className="flex-1 min-w-40 border border-gray-300 rounded-lg px-2 py-1" />
<input name="sku" placeholder="SKU" required className="w-28 border border-gray-300 rounded-lg px-2 py-1" />
<label className="flex items-center gap-1 text-gray-500">
<input type="checkbox" name="esDefault" /> default
</label>
<button className="bg-black text-white rounded-lg px-3 py-1 font-medium">Añadir</button>
</form>
</section>
);
})}
{/* Import CSV */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-2">Importar catálogo (CSV)</h2>
<p className="text-xs text-gray-500 mb-3">
Cabecera: <code>categoria,nombre,calidad,precio,unidad,descriptor_render,sku</code>. El
precio en euros. Actualiza por SKU.
</p>
<form action={importarCatalogoCsv as unknown as (fd: FormData) => void}>
<textarea
name="csv"
rows={5}
placeholder="categoria,nombre,calidad,precio,unidad,descriptor_render,sku"
className="w-full border border-gray-300 rounded-lg px-3 py-2 font-mono text-xs"
/>
<button className="mt-2 bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
Importar
</button>
</form>
</section>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { deriveCantidades } from './derive';
import { resolvePrecioUnitario } from './resolve';
import { PARTIDA_LABEL, PARTIDA_ORDER } from './labels';
import type {
BudgetInputs,
BudgetResult,
CategoriaMaterial,
CatalogItem,
PartidaKey,
PricingConfig,
} from './types';
const LICENCIA_MIN = 30000; // 300 €
const LICENCIA_MAX = 150000; // 1.500 €
// A qué partida contribuye el material de cada categoría.
const MATERIAL_PARTIDA: Record<CategoriaMaterial, PartidaKey> = {
suelo: 'alicatado',
pared: 'alicatado',
pintura: 'extras',
mobiliario: 'carpinteria',
};
const CATEGORIAS: CategoriaMaterial[] = ['suelo', 'pared', 'pintura', 'mobiliario'];
export function computeBudget(
inputs: BudgetInputs,
config: PricingConfig,
catalog: CatalogItem[],
): BudgetResult {
const cant = deriveCantidades(inputs, config);
const avisos: string[] = [];
const materialesRender: string[] = [];
const importes: Record<PartidaKey, number> = {
demolicion: 0,
alicatado: 0,
fontaneria: 0,
electricidad: 0,
carpinteria: 0,
mano_de_obra: 0,
extras: 0,
licencia: 0,
};
const cantidadPorCategoria: Record<CategoriaMaterial, number> = {
suelo: cant.m2Suelo,
pared: cant.m2Pared,
pintura: cant.m2Pared,
mobiliario: cant.mlMobiliario,
};
for (const categoria of CATEGORIAS) {
const cantidad = cantidadPorCategoria[categoria];
if (cantidad <= 0) continue;
const { item } = resolvePrecioUnitario(
categoria,
inputs.calidadGlobal,
catalog,
inputs.materialSelections,
);
if (!item) {
avisos.push(`Sin precio para ${categoria} (calidad ${inputs.calidadGlobal})`);
continue;
}
importes[MATERIAL_PARTIDA[categoria]] += Math.round(cantidad * item.precioUnit);
if (item.descriptorRender) materialesRender.push(item.descriptorRender);
}
importes.demolicion += Math.round(cant.m2Suelo * config.manoObra.demolicion);
importes.fontaneria += Math.round(cant.m2Suelo * config.manoObra.fontaneria);
importes.electricidad += Math.round(cant.m2Suelo * config.manoObra.electricidad);
importes.mano_de_obra += Math.round(cant.m2Suelo * config.manoObra.mano_de_obra);
if (inputs.estructural) importes.licencia += LICENCIA_MIN;
const partidas = PARTIDA_ORDER.filter((k) => importes[k] > 0).map((k) => ({
key: k,
label: PARTIDA_LABEL[k],
importe: importes[k],
}));
const subtotal = partidas.reduce((s, p) => s + p.importe, 0);
const factorZona = config.factorZona[inputs.provincia ?? ''] ?? 1;
const total = Math.round(subtotal * factorZona);
const hasExact = (Object.values(inputs.materialSelections) as string[]).some(
(id) => catalog.some((c) => c.id === id),
);
const hasM2 = inputs.m2Suelo != null && inputs.m2Suelo > 0;
let confianza: BudgetResult['confianza'];
let band: number;
if (hasM2 && hasExact) {
confianza = 'alta';
band = 0.1;
} else if (hasM2 || hasExact) {
confianza = 'media';
band = 0.15;
} else {
confianza = 'baja';
band = 0.25;
}
const rango = {
min: Math.round(total * (1 - band)),
max: Math.round(total * (1 + band)) + (inputs.estructural ? LICENCIA_MAX - LICENCIA_MIN : 0),
};
return { partidas, subtotal, factorZona, total, rango, confianza, materialesRender, avisos };
}

76
mvp/b2c/src/budget/csv.ts Normal file
View File

@@ -0,0 +1,76 @@
import { z } from 'zod';
import type { CategoriaMaterial, Calidad, Unidad } from './types';
export interface ParsedCatalogRow {
categoria: CategoriaMaterial;
nombre: string;
calidad: Calidad;
precioUnit: number; // céntimos
unidad: Unidad;
descriptorRender: string;
sku: string;
}
export interface CsvError {
line: number; // 1-indexed (la cabecera es la línea 1)
message: string;
}
const HEADER = ['categoria', 'nombre', 'calidad', 'precio', 'unidad', 'descriptor_render', 'sku'];
const rowSchema = z.object({
categoria: z.enum(['suelo', 'pared', 'pintura', 'mobiliario']),
nombre: z.string().min(1),
calidad: z.enum(['basica', 'media', 'premium']),
precio: z
.string()
.transform((s) => Number(s))
.refine((n) => Number.isFinite(n) && n > 0, 'precio inválido'),
unidad: z.enum(['m2', 'ml', 'ud']),
descriptor_render: z.string(),
sku: z.string().min(1),
});
export function parseCatalogCsv(text: string): { rows: ParsedCatalogRow[]; errors: CsvError[] } {
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length === 0) {
return { rows: [], errors: [{ line: 1, message: 'CSV vacío' }] };
}
const header = lines[0].split(',').map((h) => h.trim());
if (HEADER.some((h, i) => header[i] !== h)) {
return {
rows: [],
errors: [{ line: 1, message: `Cabecera inválida. Esperada: ${HEADER.join(',')}` }],
};
}
const rows: ParsedCatalogRow[] = [];
const errors: CsvError[] = [];
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(',').map((c) => c.trim());
const record = Object.fromEntries(HEADER.map((h, idx) => [h, cells[idx] ?? '']));
const parsed = rowSchema.safeParse(record);
if (!parsed.success) {
errors.push({ line: i + 1, message: parsed.error.issues[0]?.message ?? 'fila inválida' });
continue;
}
const d = parsed.data;
rows.push({
categoria: d.categoria,
nombre: d.nombre,
calidad: d.calidad,
precioUnit: Math.round(d.precio * 100),
unidad: d.unidad,
descriptorRender: d.descriptor_render,
sku: d.sku,
});
}
return { rows, errors };
}

View File

@@ -0,0 +1,39 @@
import type { BudgetInputs, PricingConfig, TipoReforma } from './types';
export interface Cantidades {
m2Suelo: number;
m2Pared: number;
mlMobiliario: number;
perimetro: number;
alturaTecho: number;
}
const M2_MEDIANA: Record<TipoReforma, number> = {
cocina: 10,
bano: 5,
salon: 20,
comedor: 16,
integral: 70,
otro: 12,
};
// Metros lineales de mobiliario por metro de perímetro. Solo cocina/baño.
const FACTOR_MOBILIARIO: Partial<Record<TipoReforma, number>> = {
cocina: 0.5,
bano: 0.3,
};
export function deriveCantidades(inputs: BudgetInputs, config: PricingConfig): Cantidades {
const m2Suelo =
inputs.m2Suelo != null && inputs.m2Suelo > 0
? inputs.m2Suelo
: M2_MEDIANA[inputs.tipoReforma];
const alturaTecho =
inputs.alturaTecho != null && inputs.alturaTecho > 0
? inputs.alturaTecho
: config.alturaTechoDefault;
const perimetro = 4 * Math.sqrt(m2Suelo);
const m2Pared = perimetro * alturaTecho;
const mlMobiliario = perimetro * (FACTOR_MOBILIARIO[inputs.tipoReforma] ?? 0);
return { m2Suelo, m2Pared, mlMobiliario, perimetro, alturaTecho };
}

View File

@@ -0,0 +1,6 @@
export * from './types';
export { PARTIDA_LABEL, PARTIDA_ORDER } from './labels';
export { deriveCantidades } from './derive';
export { resolvePrecioUnitario } from './resolve';
export { computeBudget } from './compute';
export { parseCatalogCsv } from './csv';

View File

@@ -0,0 +1,23 @@
import type { PartidaKey } from './types';
export const PARTIDA_ORDER: PartidaKey[] = [
'demolicion',
'alicatado',
'fontaneria',
'electricidad',
'carpinteria',
'mano_de_obra',
'extras',
'licencia',
];
export const PARTIDA_LABEL: Record<PartidaKey, string> = {
demolicion: 'Demolición',
alicatado: 'Alicatado y solado',
fontaneria: 'Fontanería',
electricidad: 'Electricidad',
carpinteria: 'Carpintería y mobiliario',
mano_de_obra: 'Mano de obra',
extras: 'Pintura y extras',
licencia: 'Licencia + Proyecto técnico',
};

View File

@@ -0,0 +1,18 @@
import type { Calidad, CategoriaMaterial, CatalogItem } from './types';
export function resolvePrecioUnitario(
categoria: CategoriaMaterial,
calidad: Calidad,
catalog: CatalogItem[],
selections: Partial<Record<CategoriaMaterial, string>>,
): { item: CatalogItem | null } {
const selectedId = selections[categoria];
if (selectedId) {
const selected = catalog.find((c) => c.id === selectedId);
if (selected) return { item: selected };
}
const def = catalog.find(
(c) => c.categoria === categoria && c.calidad === calidad && c.esDefault,
);
return { item: def ?? null };
}

View File

@@ -0,0 +1,61 @@
export type Calidad = 'basica' | 'media' | 'premium';
export type Unidad = 'm2' | 'ml' | 'ud';
export type CategoriaMaterial = 'suelo' | 'pared' | 'pintura' | 'mobiliario';
export type TipoReforma = 'cocina' | 'bano' | 'salon' | 'comedor' | 'integral' | 'otro';
export type PartidaKey =
| 'demolicion'
| 'alicatado'
| 'fontaneria'
| 'electricidad'
| 'carpinteria'
| 'mano_de_obra'
| 'extras'
| 'licencia';
export type ManoObraKey = 'demolicion' | 'fontaneria' | 'electricidad' | 'mano_de_obra';
export interface CatalogItem {
id: string;
categoria: CategoriaMaterial;
nombre: string;
calidad: Calidad;
precioUnit: number; // céntimos por unidad
unidad: Unidad;
descriptorRender: string;
esDefault: boolean;
sku: string;
}
export interface PricingConfig {
alturaTechoDefault: number; // metros
factorZona: Record<string, number>; // provincia -> multiplicador
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
}
export interface BudgetInputs {
tipoReforma: TipoReforma;
m2Suelo: number | null;
alturaTecho: number | null;
calidadGlobal: Calidad;
estructural: boolean;
provincia: string | null;
materialSelections: Partial<Record<CategoriaMaterial, string>>; // categoria -> catalogItemId
}
export interface PartidaResult {
key: PartidaKey;
label: string;
importe: number; // céntimos (base, antes de factor zona)
}
export interface BudgetResult {
partidas: PartidaResult[];
subtotal: number; // céntimos
factorZona: number;
total: number; // céntimos = round(subtotal * factorZona)
rango: { min: number; max: number }; // céntimos
confianza: 'baja' | 'media' | 'alta';
materialesRender: string[]; // descriptores para el prompt del render
avisos: string[];
}

View File

@@ -0,0 +1,54 @@
import { eq } from 'drizzle-orm';
import { db } from './index';
import { pricingConfig, catalogItems, tenants } from './schema';
import { TENANT_SLUG } from '@/lib/funnel';
import type { PricingConfig, CatalogItem, ManoObraKey } from '@/budget/types';
async function getTenantId(): Promise<string> {
const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1);
if (!tenant) throw new Error(`Tenant "${TENANT_SLUG}" no existe. ¿Has corrido npm run db:seed?`);
return tenant.id;
}
const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = {
demolicion: 0,
fontaneria: 0,
electricidad: 0,
mano_de_obra: 0,
};
export async function getPricingConfig(): Promise<PricingConfig> {
const tenantId = await getTenantId();
const [row] = await db
.select()
.from(pricingConfig)
.where(eq(pricingConfig.tenantId, tenantId))
.limit(1);
if (!row) {
return { alturaTechoDefault: 2.5, factorZona: {}, manoObra: { ...MANO_OBRA_DEFAULT } };
}
return {
alturaTechoDefault: row.alturaTechoDefault,
factorZona: row.factorZona,
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
};
}
export async function getCatalog(): Promise<CatalogItem[]> {
const tenantId = await getTenantId();
const rows = await db.select().from(catalogItems).where(eq(catalogItems.tenantId, tenantId));
return rows.map((r) => ({
id: r.id,
categoria: r.categoria,
nombre: r.nombre,
calidad: r.calidad,
precioUnit: r.precioUnit,
unidad: r.unidad,
descriptorRender: r.descriptorRender,
esDefault: r.esDefault,
sku: r.sku,
}));
}
export { getTenantId };

View File

@@ -9,6 +9,8 @@ import {
timestamp, timestamp,
jsonb, jsonb,
index, index,
doublePrecision,
uniqueIndex,
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
// Estado comercial del lead — RF-D-03. Lo que el reformista gestiona a mano. // Estado comercial del lead — RF-D-03. Lo que el reformista gestiona a mano.
@@ -42,6 +44,17 @@ export const tipoReforma = pgEnum('tipo_reforma', [
'otro', 'otro',
]); ]);
export const calidad = pgEnum('calidad', ['basica', 'media', 'premium']);
export const categoriaMaterial = pgEnum('categoria_material', [
'suelo',
'pared',
'pintura',
'mobiliario',
]);
export const unidadMedida = pgEnum('unidad_medida', ['m2', 'ml', 'ud']);
// Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded. // Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded.
// Multi-tenant real es F1.5; la tabla ya queda lista para ello. // Multi-tenant real es F1.5; la tabla ya queda lista para ello.
export const tenants = pgTable('tenants', { export const tenants = pgTable('tenants', {
@@ -91,6 +104,17 @@ export const leads = pgTable(
audioUrl: text('audio_url'), audioUrl: text('audio_url'),
notas: text('notas'), notas: text('notas'),
// 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),
materialSelections: jsonb('material_selections')
.$type<Record<string, string>>()
.notNull()
.default({}),
desgloseSnapshot: jsonb('desglose_snapshot'),
}, },
(table) => [ (table) => [
index('leads_tenant_created_idx').on(table.tenantId, table.createdAt), index('leads_tenant_created_idx').on(table.tenantId, table.createdAt),
@@ -144,6 +168,42 @@ export const precisionHistory = pgTable('precision_history', {
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), 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({}),
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 Tenant = typeof tenants.$inferSelect;
export type Lead = typeof leads.$inferSelect; export type Lead = typeof leads.$inferSelect;
export type NewLead = typeof leads.$inferInsert; export type NewLead = typeof leads.$inferInsert;
@@ -151,3 +211,6 @@ export type LeadFoto = typeof leadFotos.$inferSelect;
export type LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect; export type LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect;
export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect; export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect;
export type PrecisionHistory = typeof precisionHistory.$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;

View File

@@ -363,6 +363,66 @@ async function main() {
} }
} }
// --- Precios + catálogo demo (motor de presupuesto) ---
const [tenantRow] = await db
.select()
.from(schema.tenants)
.where(eq(schema.tenants.slug, 'reformas-ejemplo'))
.limit(1);
if (tenantRow) {
await db.delete(schema.catalogItems).where(eq(schema.catalogItems.tenantId, tenantRow.id));
await db.delete(schema.pricingConfig).where(eq(schema.pricingConfig.tenantId, tenantRow.id));
await db.insert(schema.pricingConfig).values({
tenantId: tenantRow.id,
alturaTechoDefault: 2.5,
factorZona: { Madrid: 1.1, Barcelona: 1.15, Valencia: 1.0, Sevilla: 0.95 },
manoObra: { demolicion: 1800, fontaneria: 2200, electricidad: 1600, mano_de_obra: 3500 },
});
const cat = (
categoria: 'suelo' | 'pared' | 'pintura' | 'mobiliario',
nombre: string,
calidad: 'basica' | 'media' | 'premium',
precioEuros: number,
unidad: 'm2' | 'ml' | 'ud',
descriptorRender: string,
sku: string,
) => ({
tenantId: tenantRow.id,
categoria,
nombre,
calidad,
precioUnit: Math.round(precioEuros * 100),
unidad,
descriptorRender,
esDefault: true,
sku,
});
await db.insert(schema.catalogItems).values([
cat('suelo', 'Gres cerámico básico', 'basica', 16, '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 gran formato', 'premium', 48, '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 rectificado', 'media', 24, '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('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', 'Esmalte premium', 'premium', 14, 'm2', 'esmalte al agua acabado seda gris perla', 'PIN-P'),
cat('mobiliario', 'Muebles melamina', 'basica', 180, 'ml', 'muebles cocina melamina blanca', 'MOB-B'),
cat('mobiliario', 'Muebles laminado', 'media', 320, 'ml', 'muebles cocina laminado roble con tirador integrado', 'MOB-M'),
cat('mobiliario', 'Muebles lacado', 'premium', 550, 'ml', 'muebles cocina lacado mate antracita y encimera porcelánica', 'MOB-P'),
]);
// Inputs demo en un lead ya avanzado para poder recalcular su presupuesto.
await db
.update(schema.leads)
.set({ m2Suelo: 12, calidadGlobal: 'media', estructural: false })
.where(eq(schema.leads.email, 'roberto.salas@example.com'));
}
console.log('Seed completado.'); console.log('Seed completado.');
await client.end(); await client.end();
} }

View File

@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest';
import { computeBudget } from '@/budget/compute';
import type { BudgetInputs, CatalogItem, PricingConfig } from '@/budget/types';
const config: PricingConfig = {
alturaTechoDefault: 2.5,
factorZona: { Madrid: 1.1 },
manoObra: { demolicion: 1500, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 },
};
const catalog: CatalogItem[] = [
{ id: 'suelo-m', categoria: 'suelo', nombre: 'Cerámico', calidad: 'media', precioUnit: 2800, unidad: 'm2', descriptorRender: 'suelo cerámico gris', esDefault: true, sku: 'SUE-M' },
{ id: 'pared-m', categoria: 'pared', nombre: 'Azulejo', calidad: 'media', precioUnit: 2400, unidad: 'm2', descriptorRender: 'azulejo blanco', esDefault: true, sku: 'PAR-M' },
{ id: 'pintura-m', categoria: 'pintura', nombre: 'Plástica', calidad: 'media', precioUnit: 800, unidad: 'm2', descriptorRender: 'pintura blanca mate', esDefault: true, sku: 'PIN-M' },
{ id: 'mob-m', categoria: 'mobiliario', nombre: 'Muebles cocina', calidad: 'media', precioUnit: 32000, unidad: 'ml', descriptorRender: 'muebles laminado roble', esDefault: true, sku: 'MOB-M' },
{ id: 'suelo-p', categoria: 'suelo', nombre: 'Porcelánico', calidad: 'premium', precioUnit: 4500, unidad: 'm2', descriptorRender: 'porcelánico símil roble', esDefault: true, sku: 'SUE-P' },
];
function inputs(partial: Partial<BudgetInputs>): BudgetInputs {
return {
tipoReforma: 'cocina',
m2Suelo: 16,
alturaTecho: null,
calidadGlobal: 'media',
estructural: false,
provincia: 'Madrid',
materialSelections: {},
...partial,
};
}
describe('computeBudget', () => {
it('calcula partidas, subtotal, factor zona y total con números conocidos', () => {
const r = computeBudget(inputs({}), config, catalog);
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
expect(byKey.demolicion).toBe(24000); // 16*1500
expect(byKey.alicatado).toBe(140800); // 16*2800 + 40*2400
expect(byKey.fontaneria).toBe(19200); // 16*1200
expect(byKey.electricidad).toBe(16000); // 16*1000
expect(byKey.carpinteria).toBe(256000); // 8*32000
expect(byKey.mano_de_obra).toBe(48000); // 16*3000
expect(byKey.extras).toBe(32000); // 40*800
expect(byKey.licencia).toBeUndefined();
expect(r.subtotal).toBe(536000);
expect(r.factorZona).toBe(1.1);
expect(r.total).toBe(589600); // round(536000 * 1.1)
});
it('confianza media (±15%) con m² pero sin selección exacta', () => {
const r = computeBudget(inputs({}), config, catalog);
expect(r.confianza).toBe('media');
expect(r.rango.min).toBe(501160); // round(589600*0.85)
expect(r.rango.max).toBe(678040); // round(589600*1.15)
});
it('confianza alta (±10%) con m² y selección exacta', () => {
const r = computeBudget(inputs({ materialSelections: { suelo: 'suelo-p' } }), config, catalog);
expect(r.confianza).toBe('alta');
expect(r.materialesRender).toContain('porcelánico símil roble');
});
it('confianza baja (±25%) sin m² ni selección', () => {
const r = computeBudget(inputs({ m2Suelo: null }), config, catalog);
expect(r.confianza).toBe('baja');
});
it('añade partida de licencia y amplía el máximo si hay cambio estructural', () => {
const r = computeBudget(inputs({ estructural: true }), config, catalog);
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
expect(byKey.licencia).toBe(30000); // 300€ mínimo
expect(r.rango.max).toBe(835990); // ver nota del plan: subtotal+30000, ×1.1, banda ±15%, + (LICENCIA_MAX - LICENCIA_MIN)
});
it('emite aviso cuando falta precio de una categoría', () => {
const sinPintura = catalog.filter((c) => c.categoria !== 'pintura');
const r = computeBudget(inputs({}), config, sinPintura);
expect(r.avisos.some((a) => a.includes('pintura'))).toBe(true);
});
it('usa factor zona 1 para provincia desconocida', () => {
const r = computeBudget(inputs({ provincia: 'Cuenca' }), config, catalog);
expect(r.factorZona).toBe(1);
expect(r.total).toBe(536000); // subtotal sin factor
});
it('respeta un factor zona explícito de 0', () => {
const configZero: PricingConfig = { ...config, factorZona: { ...config.factorZona, Madrid: 0 } };
const r = computeBudget(inputs({ provincia: 'Madrid' }), configZero, catalog);
expect(r.factorZona).toBe(0);
expect(r.total).toBe(0);
});
it('no sube la confianza si la selección apunta a un material inexistente', () => {
const r = computeBudget(inputs({ materialSelections: { suelo: 'no-existe' } }), config, catalog);
expect(r.confianza).toBe('media'); // tiene m² pero la selección no resuelve -> no es alta
});
it('omite carpintería y no rompe para tipos sin mobiliario (salón)', () => {
const r = computeBudget(inputs({ tipoReforma: 'salon' }), config, catalog);
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
expect(byKey.carpinteria).toBeUndefined();
expect(r.total).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { parseCatalogCsv } from '@/budget/csv';
const HEADER = 'categoria,nombre,calidad,precio,unidad,descriptor_render,sku';
describe('parseCatalogCsv', () => {
it('parsea filas válidas y convierte precio a céntimos', () => {
const csv = [
HEADER,
'suelo,Cerámico gris,media,28.00,m2,suelo cerámico gris,SUE-M',
'mobiliario,Muebles cocina,premium,550,ml,muebles laminado roble,MOB-P',
].join('\n');
const { rows, errors } = parseCatalogCsv(csv);
expect(errors).toHaveLength(0);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
categoria: 'suelo',
calidad: 'media',
precioUnit: 2800,
unidad: 'm2',
sku: 'SUE-M',
});
expect(rows[1].precioUnit).toBe(55000);
});
it('reporta errores por fila sin abortar las válidas', () => {
const csv = [
HEADER,
'suelo,Bueno,media,28,m2,desc,SUE-M',
'inventada,Malo,media,10,m2,desc,X', // categoria inválida
'pared,Sin precio,media,abc,m2,desc,PAR-M', // precio no numérico
].join('\n');
const { rows, errors } = parseCatalogCsv(csv);
expect(rows).toHaveLength(1);
expect(errors).toHaveLength(2);
expect(errors[0].line).toBe(3); // 1-indexed incluyendo cabecera
expect(errors[1].line).toBe(4);
});
it('devuelve error global si falta la cabecera esperada', () => {
const { rows, errors } = parseCatalogCsv('a,b,c\n1,2,3');
expect(rows).toHaveLength(0);
expect(errors[0].message).toMatch(/cabecera/i);
});
});

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { deriveCantidades } from '@/budget/derive';
import type { BudgetInputs, PricingConfig } from '@/budget/types';
const config: PricingConfig = {
alturaTechoDefault: 2.5,
factorZona: {},
manoObra: { demolicion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0 },
};
function inputs(partial: Partial<BudgetInputs>): BudgetInputs {
return {
tipoReforma: 'cocina',
m2Suelo: null,
alturaTecho: null,
calidadGlobal: 'media',
estructural: false,
provincia: null,
materialSelections: {},
...partial,
};
}
describe('deriveCantidades', () => {
it('usa m² aportados y deriva perímetro y paredes con números limpios', () => {
// m2Suelo=16 -> sqrt=4 -> perimetro=16 -> pared=16*2.5=40 -> mobiliario=16*0.5=8 (cocina)
const c = deriveCantidades(inputs({ m2Suelo: 16 }), config);
expect(c.m2Suelo).toBe(16);
expect(c.perimetro).toBe(16);
expect(c.m2Pared).toBe(40);
expect(c.mlMobiliario).toBe(8);
expect(c.alturaTecho).toBe(2.5);
});
it('cae a la mediana por tipo cuando no hay m²', () => {
const c = deriveCantidades(inputs({ tipoReforma: 'bano', m2Suelo: null }), config);
expect(c.m2Suelo).toBe(5); // mediana baño
});
it('usa la altura aportada por encima del default', () => {
const c = deriveCantidades(inputs({ m2Suelo: 16, alturaTecho: 3 }), config);
expect(c.alturaTecho).toBe(3);
expect(c.m2Pared).toBe(48); // 16 * 3
});
it('no calcula mobiliario para tipos sin cocina/baño', () => {
const c = deriveCantidades(inputs({ tipoReforma: 'salon', m2Suelo: 16 }), config);
expect(c.mlMobiliario).toBe(0);
});
});

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { resolvePrecioUnitario } from '@/budget/resolve';
import type { CatalogItem } from '@/budget/types';
const catalog: CatalogItem[] = [
{ id: 's-media', categoria: 'suelo', nombre: 'Cerámico medio', calidad: 'media', precioUnit: 2800, unidad: 'm2', descriptorRender: 'suelo cerámico gris', esDefault: true, sku: 'SUE-M' },
{ id: 's-premium', categoria: 'suelo', nombre: 'Porcelánico roble', calidad: 'premium', precioUnit: 4500, unidad: 'm2', descriptorRender: 'porcelánico símil roble', esDefault: true, sku: 'SUE-P' },
];
describe('resolvePrecioUnitario', () => {
it('devuelve el default de la calidad cuando no hay selección', () => {
const { item } = resolvePrecioUnitario('suelo', 'media', catalog, {});
expect(item?.id).toBe('s-media');
});
it('prioriza la selección exacta sobre la calidad global', () => {
const { item } = resolvePrecioUnitario('suelo', 'media', catalog, { suelo: 's-premium' });
expect(item?.id).toBe('s-premium');
});
it('devuelve null si no hay default para esa calidad ni selección', () => {
const { item } = resolvePrecioUnitario('pared', 'media', catalog, {});
expect(item).toBeNull();
});
});

View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('vitest setup', () => {
it('runs', () => {
expect(1 + 1).toBe(2);
});
});

17
mvp/b2c/vitest.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
export default defineConfig({
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
test: {
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/budget/**'],
thresholds: { lines: 70, functions: 70, statements: 70, branches: 70 },
},
},
});