Compare commits
15 Commits
f09024f753
...
6be00e3eb5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6be00e3eb5 | ||
|
|
588aa4dc1c | ||
|
|
4106d58614 | ||
|
|
c00c571549 | ||
|
|
892c257182 | ||
|
|
afef9f2cb0 | ||
|
|
e6f8b47205 | ||
|
|
58d3f62a76 | ||
|
|
896c7ac89b | ||
|
|
61e0f5dbe5 | ||
|
|
b27b68908c | ||
|
|
9b14dbfac5 | ||
|
|
515e9fd7a2 | ||
|
|
75de172900 | ||
|
|
bd07586b03 |
1610
docs/superpowers/plans/2026-05-30-motor-presupuesto.md
Normal file
1610
docs/superpowers/plans/2026-05-30-motor-presupuesto.md
Normal file
File diff suppressed because it is too large
Load Diff
137
docs/superpowers/specs/2026-05-30-motor-presupuesto-design.md
Normal file
137
docs/superpowers/specs/2026-05-30-motor-presupuesto-design.md
Normal 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 300–1.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.
|
||||
36
mvp/b2c/drizzle/0001_bored_preak.sql
Normal file
36
mvp/b2c/drizzle/0001_bored_preak.sql
Normal 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");
|
||||
834
mvp/b2c/drizzle/meta/0001_snapshot.json
Normal file
834
mvp/b2c/drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1780056789929,
|
||||
"tag": "0000_motionless_jackpot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1780137082579,
|
||||
"tag": "0001_bored_preak",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
1424
mvp/b2c/package-lock.json
generated
1424
mvp/b2c/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,10 @@
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"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": {
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
@@ -21,17 +24,20 @@
|
||||
"postgres": "^3.4.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"tailwindcss": "^4.3.0"
|
||||
"tailwindcss": "^4.3.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
formatEuros,
|
||||
formatFecha,
|
||||
} from '@/lib/funnel';
|
||||
import { recalcularPresupuesto } from '../actions';
|
||||
import type { BudgetResult } from '@/budget/types';
|
||||
|
||||
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 reachedStages = new Set(eventos.map((e) => e.stage));
|
||||
|
||||
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
||||
const desglose = snapshot?.result ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
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 { getPricingConfig, getCatalog } from '@/db/pricing-queries';
|
||||
import { computeBudget } from '@/budget';
|
||||
import type { BudgetInputs } from '@/budget/types';
|
||||
|
||||
async function getTenantId(): Promise<string> {
|
||||
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/${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}`);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,14 @@ export default function PanelLayout({ children }: { children: React.ReactNode })
|
||||
<span className="text-gray-300">/</span>
|
||||
<span className="text-sm font-medium text-gray-600">Reformas Ejemplo</span>
|
||||
</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>
|
||||
</header>
|
||||
<main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
|
||||
|
||||
105
mvp/b2c/src/app/panel/precios/actions.ts
Normal file
105
mvp/b2c/src/app/panel/precios/actions.ts
Normal 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: [] };
|
||||
}
|
||||
147
mvp/b2c/src/app/panel/precios/page.tsx
Normal file
147
mvp/b2c/src/app/panel/precios/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
mvp/b2c/src/budget/compute.ts
Normal file
110
mvp/b2c/src/budget/compute.ts
Normal 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
76
mvp/b2c/src/budget/csv.ts
Normal 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 };
|
||||
}
|
||||
39
mvp/b2c/src/budget/derive.ts
Normal file
39
mvp/b2c/src/budget/derive.ts
Normal 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 };
|
||||
}
|
||||
6
mvp/b2c/src/budget/index.ts
Normal file
6
mvp/b2c/src/budget/index.ts
Normal 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';
|
||||
23
mvp/b2c/src/budget/labels.ts
Normal file
23
mvp/b2c/src/budget/labels.ts
Normal 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',
|
||||
};
|
||||
18
mvp/b2c/src/budget/resolve.ts
Normal file
18
mvp/b2c/src/budget/resolve.ts
Normal 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 };
|
||||
}
|
||||
61
mvp/b2c/src/budget/types.ts
Normal file
61
mvp/b2c/src/budget/types.ts
Normal 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[];
|
||||
}
|
||||
54
mvp/b2c/src/db/pricing-queries.ts
Normal file
54
mvp/b2c/src/db/pricing-queries.ts
Normal 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 };
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
doublePrecision,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
// 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',
|
||||
]);
|
||||
|
||||
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.
|
||||
// Multi-tenant real es F1.5; la tabla ya queda lista para ello.
|
||||
export const tenants = pgTable('tenants', {
|
||||
@@ -91,6 +104,17 @@ export const leads = pgTable(
|
||||
audioUrl: text('audio_url'),
|
||||
|
||||
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) => [
|
||||
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(),
|
||||
});
|
||||
|
||||
// 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 Lead = typeof leads.$inferSelect;
|
||||
export type NewLead = typeof leads.$inferInsert;
|
||||
@@ -151,3 +211,6 @@ export type LeadFoto = typeof leadFotos.$inferSelect;
|
||||
export type LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect;
|
||||
export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect;
|
||||
export type PrecisionHistory = typeof precisionHistory.$inferSelect;
|
||||
export type PricingConfigRow = typeof pricingConfig.$inferSelect;
|
||||
export type CatalogItemRow = typeof catalogItems.$inferSelect;
|
||||
export type NewCatalogItem = typeof catalogItems.$inferInsert;
|
||||
|
||||
@@ -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.');
|
||||
await client.end();
|
||||
}
|
||||
|
||||
104
mvp/b2c/tests/budget/compute.test.ts
Normal file
104
mvp/b2c/tests/budget/compute.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
mvp/b2c/tests/budget/csv.test.ts
Normal file
45
mvp/b2c/tests/budget/csv.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
mvp/b2c/tests/budget/derive.test.ts
Normal file
50
mvp/b2c/tests/budget/derive.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
25
mvp/b2c/tests/budget/resolve.test.ts
Normal file
25
mvp/b2c/tests/budget/resolve.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
7
mvp/b2c/tests/smoke.test.ts
Normal file
7
mvp/b2c/tests/smoke.test.ts
Normal 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
17
mvp/b2c/vitest.config.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user