From 2e3cd782169e84e9319e839cba0848789b9a2716 Mon Sep 17 00:00:00 2001 From: Goyo Cancio Date: Thu, 4 Jun 2026 14:02:57 +0200 Subject: [PATCH] =?UTF-8?q?A=C3=B1ade=20impermeabilizaci=C3=B3n,=20extras?= =?UTF-8?q?=20fijos=20y=20zonas=20al=20motor=20de=20presupuesto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acerca el cálculo a tarifas de mercado sin rehacer el modelo lineal €/m²: - Impermeabilización como partida propia en zonas húmedas (cocina/baño/integral) - Extras fijos que no escalan con m²: boletín (siempre), tuberías (piso anterior a 2000) y cambio de distribución (mover inodoro/ducha/bañera) - Intensidad por tipo en fontanería/electricidad (baseline cocina) para que un integral no escale como un baño - Factor de zona por provincia en tramos (Madrid/BCN 1.40, islas 1.30, capitales 1.20, rural 0.85, resto 1.00) - 2 preguntas nuevas en el formulario del cliente para disparar los extras - Panel de precios: campo de impermeabilización + sección de extras fijos - Seed recalibrado (mano de obra, extras, catálogo suelo/pared) - Migración 0009 (leads.anterior_a_2000, leads.cambio_distribucion, pricing_config.extras) - Tests del motor ampliados (impermeabilización, extras, intensidad por tipo) Co-Authored-By: Claude Opus 4.8 --- mvp/b2c/.gitignore | 3 + mvp/b2c/drizzle/0009_white_agent_brand.sql | 3 + mvp/b2c/drizzle/meta/0009_snapshot.json | 1694 +++++++++++++++++ mvp/b2c/drizzle/meta/_journal.json | 7 + mvp/b2c/src/app/panel/precios/actions.ts | 17 + mvp/b2c/src/app/panel/precios/page.tsx | 35 + mvp/b2c/src/app/solicitud/actions.ts | 4 + mvp/b2c/src/budget/compute.ts | 36 +- mvp/b2c/src/budget/labels.ts | 4 + mvp/b2c/src/budget/types.ts | 19 +- .../src/components/funnel/FormularioZonas.tsx | 18 +- mvp/b2c/src/db/pricing-queries.ts | 11 +- mvp/b2c/src/db/schema.ts | 8 + mvp/b2c/src/db/seed.ts | 43 +- mvp/b2c/src/lib/funnel/orchestrator.ts | 2 + mvp/b2c/src/lib/voice/apply.ts | 4 + mvp/b2c/tests/budget/compute.test.ts | 49 +- mvp/b2c/tests/budget/derive.test.ts | 2 +- 18 files changed, 1941 insertions(+), 18 deletions(-) create mode 100644 mvp/b2c/drizzle/0009_white_agent_brand.sql create mode 100644 mvp/b2c/drizzle/meta/0009_snapshot.json diff --git a/mvp/b2c/.gitignore b/mvp/b2c/.gitignore index 04a2ce3..f2cc55f 100644 --- a/mvp/b2c/.gitignore +++ b/mvp/b2c/.gitignore @@ -43,3 +43,6 @@ next-env.d.ts # Colección Postman con la FUNNEL_API_KEY embebida — no commitear api-docs/reformix-ingesta.postman_collection.json + +# Logs locales del dev server +dev.log diff --git a/mvp/b2c/drizzle/0009_white_agent_brand.sql b/mvp/b2c/drizzle/0009_white_agent_brand.sql new file mode 100644 index 0000000..37365bb --- /dev/null +++ b/mvp/b2c/drizzle/0009_white_agent_brand.sql @@ -0,0 +1,3 @@ +ALTER TABLE "leads" ADD COLUMN "anterior_a_2000" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "leads" ADD COLUMN "cambio_distribucion" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "pricing_config" ADD COLUMN "extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::jsonb NOT NULL; \ No newline at end of file diff --git a/mvp/b2c/drizzle/meta/0009_snapshot.json b/mvp/b2c/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..fda1767 --- /dev/null +++ b/mvp/b2c/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1694 @@ +{ + "id": "9e6e7bd9-61c0-4fa2-826e-67a3d100f8d3", + "prevId": "2e5c9ab4-788d-4ae1-8b6e-7b4f471b97e2", + "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.galeria_fotos": { + "name": "galeria_fotos", + "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 + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "titulo": { + "name": "titulo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": { + "galeria_tenant_idx": { + "name": "galeria_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "galeria_fotos_tenant_id_tenants_id_fk": { + "name": "galeria_fotos_tenant_id_tenants_id_fk", + "tableFrom": "galeria_fotos", + "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 + }, + "momento": { + "name": "momento", + "type": "foto_momento", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'antes'" + }, + "zona": { + "name": "zona", + "type": "tipo_reforma", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "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_notas": { + "name": "lead_notas", + "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 + }, + "zona": { + "name": "zona", + "type": "tipo_reforma", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "texto": { + "name": "texto", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origen": { + "name": "origen", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ep'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "lead_notas_lead_id_leads_id_fk": { + "name": "lead_notas_lead_id_leads_id_fk", + "tableFrom": "lead_notas", + "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 + }, + "testimonio_solicitado_at": { + "name": "testimonio_solicitado_at", + "type": "timestamp with time zone", + "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 + }, + "anterior_a_2000": { + "name": "anterior_a_2000", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cambio_distribucion": { + "name": "cambio_distribucion", + "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 + }, + "urgencia": { + "name": "urgencia", + "type": "urgencia", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "presupuesto_target": { + "name": "presupuesto_target", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "taste_text": { + "name": "taste_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferences_snapshot": { + "name": "preferences_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.plans": { + "name": "plans", + "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": { + "name": "nombre", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "precio_mensual": { + "name": "precio_mensual", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "leads_incluidos": { + "name": "leads_incluidos", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "activo": { + "name": "activo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plans_slug_unique": { + "name": "plans_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "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" + }, + "extras": { + "name": "extras", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"tuberias\":0,\"boletin\":0,\"distribucion\":0}'::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.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "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 + }, + "seo_title": { + "name": "seo_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seo_description": { + "name": "seo_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "about_enabled": { + "name": "about_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "about_foto_url": { + "name": "about_foto_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "about_texto": { + "name": "about_texto", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "anios_experiencia": { + "name": "anios_experiencia", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "theme_preset": { + "name": "theme_preset", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pizarra'" + }, + "theme_color": { + "name": "theme_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cif": { + "name": "cif", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direccion": { + "name": "direccion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telefono": { + "name": "telefono", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web": { + "name": "web", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_id": { + "name": "plan_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subscription_status": { + "name": "subscription_status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'trial'" + }, + "envio_presupuesto": { + "name": "envio_presupuesto", + "type": "envio_presupuesto_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'automatico'" + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenants_plan_id_plans_id_fk": { + "name": "tenants_plan_id_plans_id_fk", + "tableFrom": "tenants", + "tableTo": "plans", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenants_slug_unique": { + "name": "tenants_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.testimonio_fotos": { + "name": "testimonio_fotos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "testimonio_id": { + "name": "testimonio_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": { + "testimonio_fotos_testimonio_id_testimonios_id_fk": { + "name": "testimonio_fotos_testimonio_id_testimonios_id_fk", + "tableFrom": "testimonio_fotos", + "tableTo": "testimonios", + "columnsFrom": [ + "testimonio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.testimonios": { + "name": "testimonios", + "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 + }, + "lead_id": { + "name": "lead_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "nombre": { + "name": "nombre", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contexto": { + "name": "contexto", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "texto": { + "name": "texto", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estado": { + "name": "estado", + "type": "testimonio_estado", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pendiente'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "testimonios_tenant_estado_idx": { + "name": "testimonios_tenant_estado_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "estado", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "testimonios_lead_idx": { + "name": "testimonios_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "testimonios_tenant_id_tenants_id_fk": { + "name": "testimonios_tenant_id_tenants_id_fk", + "tableFrom": "testimonios", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "testimonios_lead_id_leads_id_fk": { + "name": "testimonios_lead_id_leads_id_fk", + "tableFrom": "testimonios", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nombre": { + "name": "nombre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reformista'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'activo'" + }, + "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()" + } + }, + "indexes": { + "users_tenant_idx": { + "name": "users_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_tenant_id_tenants_id_fk": { + "name": "users_tenant_id_tenants_id_fk", + "tableFrom": "users", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "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.envio_presupuesto_mode": { + "name": "envio_presupuesto_mode", + "schema": "public", + "values": [ + "automatico", + "revision" + ] + }, + "public.foto_momento": { + "name": "foto_momento", + "schema": "public", + "values": [ + "antes", + "despues" + ] + }, + "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.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "trial", + "activo", + "cancelado", + "vencido" + ] + }, + "public.testimonio_estado": { + "name": "testimonio_estado", + "schema": "public", + "values": [ + "pendiente", + "publicado", + "oculto" + ] + }, + "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" + ] + }, + "public.urgencia": { + "name": "urgencia", + "schema": "public", + "values": [ + "alta", + "media", + "baja" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "reformista", + "admin" + ] + }, + "public.user_status": { + "name": "user_status", + "schema": "public", + "values": [ + "activo", + "deshabilitado" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/mvp/b2c/drizzle/meta/_journal.json b/mvp/b2c/drizzle/meta/_journal.json index cec3416..404b75c 100644 --- a/mvp/b2c/drizzle/meta/_journal.json +++ b/mvp/b2c/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1780505942614, "tag": "0008_sharp_bloodaxe", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1780569557328, + "tag": "0009_white_agent_brand", + "breakpoints": true } ] } \ No newline at end of file diff --git a/mvp/b2c/src/app/panel/precios/actions.ts b/mvp/b2c/src/app/panel/precios/actions.ts index 561a50d..0ac8156 100644 --- a/mvp/b2c/src/app/panel/precios/actions.ts +++ b/mvp/b2c/src/app/panel/precios/actions.ts @@ -66,6 +66,7 @@ export async function actualizarConfig(formData: FormData) { alturaTechoDefault: parsePositive(formData.get('alturaTechoDefault'), 'altura de techo'), manoObra: { demolicion: eurosToCents(formData.get('mo_demolicion'), 'demolición'), + impermeabilizacion: eurosToCents(formData.get('mo_impermeabilizacion'), 'impermeabilizació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'), @@ -76,6 +77,22 @@ export async function actualizarConfig(formData: FormData) { revalidatePath('/panel/precios'); } +export async function actualizarExtras(formData: FormData) { + const tenantId = await getTenantId(); + await db + .update(pricingConfig) + .set({ + extras: { + tuberias: eurosToCents(formData.get('extra_tuberias'), 'renovación de tuberías'), + boletin: eurosToCents(formData.get('extra_boletin'), 'boletín eléctrico'), + distribucion: eurosToCents(formData.get('extra_distribucion'), 'cambio de distribución'), + }, + updatedAt: new Date(), + }) + .where(eq(pricingConfig.tenantId, tenantId)); + revalidatePath('/panel/precios'); +} + export async function actualizarEnvio(formData: FormData) { const tenantId = await getTenantId(); const modo = formData.get('modo'); diff --git a/mvp/b2c/src/app/panel/precios/page.tsx b/mvp/b2c/src/app/panel/precios/page.tsx index 4e6a63c..75cca9d 100644 --- a/mvp/b2c/src/app/panel/precios/page.tsx +++ b/mvp/b2c/src/app/panel/precios/page.tsx @@ -4,6 +4,7 @@ import { actualizarPrecio, borrarMaterial, actualizarConfig, + actualizarExtras, actualizarEnvio, importarCatalogoCsv, } from './actions'; @@ -96,6 +97,7 @@ export default async function PreciosPage() { {( [ ['demolicion', 'Demolición'], + ['impermeabilizacion', 'Impermeabilización'], ['fontaneria', 'Fontanería'], ['electricidad', 'Electricidad'], ['mano_de_obra', 'Mano de obra'], @@ -118,6 +120,39 @@ export default async function PreciosPage() { + {/* Extras fijos */} +
+

Extras fijos

+

+ Importes fijos que no escalan con los metros. El boletín eléctrico se aplica siempre; las + tuberías solo en pisos anteriores al año 2000 y la distribución al mover inodoro, ducha o + bañera. +

+
+ {( + [ + ['tuberias', 'Renovación de tuberías'], + ['boletin', 'Boletín eléctrico'], + ['distribucion', 'Cambio de distribución'], + ] as const + ).map(([k, etiqueta]) => ( + + ))} + +
+
+ {/* Catálogo por categoría */} {CATEGORIAS.map((categoria) => { const items = catalog.filter((c) => c.categoria === categoria); diff --git a/mvp/b2c/src/app/solicitud/actions.ts b/mvp/b2c/src/app/solicitud/actions.ts index aea15bb..a30854d 100644 --- a/mvp/b2c/src/app/solicitud/actions.ts +++ b/mvp/b2c/src/app/solicitud/actions.ts @@ -146,6 +146,8 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData): const presupuestoTarget = Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null; const estructural = formData.get('estructural') === 'on'; + const anteriorA2000 = formData.get('anteriorA2000') === 'on'; + const cambioDistribucion = formData.get('cambioDistribucion') === 'on'; let zonas = await parsearZonas(formData); if (zonas.length === 0) { @@ -191,6 +193,8 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData): urgencia, presupuestoTarget, estructural, + anteriorA2000, + cambioDistribucion, tasteText, pipelineStage: 'fotos_subidas', updatedAt: new Date(), diff --git a/mvp/b2c/src/budget/compute.ts b/mvp/b2c/src/budget/compute.ts index 756166f..7c1747e 100644 --- a/mvp/b2c/src/budget/compute.ts +++ b/mvp/b2c/src/budget/compute.ts @@ -8,11 +8,27 @@ import type { CatalogItem, PartidaKey, PricingConfig, + TipoReforma, } from './types'; const LICENCIA_MIN = 30000; // 300 € const LICENCIA_MAX = 150000; // 1.500 € +// Zonas húmedas: las únicas que llevan impermeabilización. +const WET = new Set(['cocina', 'bano', 'integral']); + +// Intensidad de instalaciones (fontanería/electricidad) por m² según el tipo de reforma. +// Baseline cocina = 1.0. Un baño concentra más instalaciones por m²; un salón o un piso +// integral las diluye. Corrige el sesgo del modelo lineal €/m² sin rehacerlo. +const TIPO_INTENSIDAD: Record = { + cocina: 1.0, + bano: 1.3, + integral: 0.45, + salon: 0.4, + comedor: 0.4, + otro: 0.7, +}; + // A qué partida contribuye el material de cada categoría. const MATERIAL_PARTIDA: Record = { suelo: 'alicatado', @@ -34,12 +50,14 @@ export function computeBudget( const importes: Record = { demolicion: 0, + impermeabilizacion: 0, alicatado: 0, fontaneria: 0, electricidad: 0, carpinteria: 0, mano_de_obra: 0, extras: 0, + extras_fijos: 0, licencia: 0, }; @@ -67,11 +85,25 @@ export function computeBudget( if (item.descriptorRender) materialesRender.push(item.descriptorRender); } + const intensidad = TIPO_INTENSIDAD[inputs.tipoReforma] ?? 1; 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.fontaneria += Math.round(cant.m2Suelo * config.manoObra.fontaneria * intensidad); + importes.electricidad += Math.round(cant.m2Suelo * config.manoObra.electricidad * intensidad); importes.mano_de_obra += Math.round(cant.m2Suelo * config.manoObra.mano_de_obra); + // Impermeabilización: solo en zonas húmedas, proporcional al suelo a tratar. + if (WET.has(inputs.tipoReforma)) { + importes.impermeabilizacion += Math.round(cant.m2Suelo * config.manoObra.impermeabilizacion); + } + + // Extras fijos (no escalan con m²). El boletín eléctrico es siempre obligatorio. + const extras = config.extras; + if (extras) { + importes.extras_fijos += extras.boletin; + if (inputs.anteriorA2000) importes.extras_fijos += extras.tuberias; + if (inputs.cambioDistribucion) importes.extras_fijos += extras.distribucion; + } + if (inputs.estructural) importes.licencia += LICENCIA_MIN; const partidas = PARTIDA_ORDER.filter((k) => importes[k] > 0).map((k) => ({ diff --git a/mvp/b2c/src/budget/labels.ts b/mvp/b2c/src/budget/labels.ts index b8dd4fb..546a574 100644 --- a/mvp/b2c/src/budget/labels.ts +++ b/mvp/b2c/src/budget/labels.ts @@ -2,22 +2,26 @@ import type { PartidaKey } from './types'; export const PARTIDA_ORDER: PartidaKey[] = [ 'demolicion', + 'impermeabilizacion', 'alicatado', 'fontaneria', 'electricidad', 'carpinteria', 'mano_de_obra', 'extras', + 'extras_fijos', 'licencia', ]; export const PARTIDA_LABEL: Record = { demolicion: 'Demolición', + impermeabilizacion: 'Impermeabilizació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', + extras_fijos: 'Extras (tuberías, boletín, distribución)', licencia: 'Licencia + Proyecto técnico', }; diff --git a/mvp/b2c/src/budget/types.ts b/mvp/b2c/src/budget/types.ts index aa3603a..0ddd0ae 100644 --- a/mvp/b2c/src/budget/types.ts +++ b/mvp/b2c/src/budget/types.ts @@ -5,15 +5,22 @@ export type TipoReforma = 'cocina' | 'bano' | 'salon' | 'comedor' | 'integral' | export type PartidaKey = | 'demolicion' + | 'impermeabilizacion' | 'alicatado' | 'fontaneria' | 'electricidad' | 'carpinteria' | 'mano_de_obra' | 'extras' + | 'extras_fijos' | 'licencia'; -export type ManoObraKey = 'demolicion' | 'fontaneria' | 'electricidad' | 'mano_de_obra'; +export type ManoObraKey = + | 'demolicion' + | 'impermeabilizacion' + | 'fontaneria' + | 'electricidad' + | 'mano_de_obra'; export interface CatalogItem { id: string; @@ -27,10 +34,18 @@ export interface CatalogItem { sku: string; } +// Extras fijos que no escalan con los m² (céntimos). Se aplican según el estado del piso. +export interface ExtrasFijos { + tuberias: number; // renovación de tuberías (pisos anteriores a 2000) + boletin: number; // boletín eléctrico (siempre obligatorio) + distribucion: number; // cambio de distribución (mover inodoro/ducha/bañera) +} + export interface PricingConfig { alturaTechoDefault: number; // metros factorZona: Record; // provincia -> multiplicador manoObra: Record; // céntimos por m² de suelo + extras?: ExtrasFijos; // importes fijos en céntimos } export interface BudgetInputs { @@ -41,6 +56,8 @@ export interface BudgetInputs { estructural: boolean; provincia: string | null; materialSelections: Partial>; // categoria -> catalogItemId + anteriorA2000?: boolean; // dispara el extra de renovación de tuberías + cambioDistribucion?: boolean; // dispara el extra de cambio de distribución } export interface PartidaResult { diff --git a/mvp/b2c/src/components/funnel/FormularioZonas.tsx b/mvp/b2c/src/components/funnel/FormularioZonas.tsx index 2e7cd38..78278af 100644 --- a/mvp/b2c/src/components/funnel/FormularioZonas.tsx +++ b/mvp/b2c/src/components/funnel/FormularioZonas.tsx @@ -271,10 +271,20 @@ export default function FormularioZonas({ - +
+ + + +
diff --git a/mvp/b2c/src/db/pricing-queries.ts b/mvp/b2c/src/db/pricing-queries.ts index 826b236..cbcc46a 100644 --- a/mvp/b2c/src/db/pricing-queries.ts +++ b/mvp/b2c/src/db/pricing-queries.ts @@ -21,11 +21,14 @@ export async function getEnvioMode(): Promise { const MANO_OBRA_DEFAULT: Record = { demolicion: 0, + impermeabilizacion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0, }; +const EXTRAS_DEFAULT = { tuberias: 0, boletin: 0, distribucion: 0 }; + export async function getPricingConfigFor(tenantId: string): Promise { const [row] = await db .select() @@ -34,12 +37,18 @@ export async function getPricingConfigFor(tenantId: string): Promise) }, + extras: { ...EXTRAS_DEFAULT, ...(row.extras ?? {}) }, }; } diff --git a/mvp/b2c/src/db/schema.ts b/mvp/b2c/src/db/schema.ts index 7e388d3..e555edd 100644 --- a/mvp/b2c/src/db/schema.ts +++ b/mvp/b2c/src/db/schema.ts @@ -199,6 +199,9 @@ export const leads = pgTable( alturaTecho: doublePrecision('altura_techo'), calidadGlobal: calidad('calidad_global'), estructural: boolean('estructural').notNull().default(false), + // Inputs de los extras fijos del presupuesto (no escalan con m²). + anteriorA2000: boolean('anterior_a_2000').notNull().default(false), + cambioDistribucion: boolean('cambio_distribucion').notNull().default(false), materialSelections: jsonb('material_selections') .$type>() .notNull() @@ -340,6 +343,11 @@ export const pricingConfig = pgTable('pricing_config', { alturaTechoDefault: doublePrecision('altura_techo_default').notNull().default(2.5), factorZona: jsonb('factor_zona').$type>().notNull().default({}), manoObra: jsonb('mano_obra').$type>().notNull().default({}), + // Extras fijos en céntimos: { tuberias, boletin, distribucion }. + extras: jsonb('extras') + .$type<{ tuberias: number; boletin: number; distribucion: number }>() + .notNull() + .default({ tuberias: 0, boletin: 0, distribucion: 0 }), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); diff --git a/mvp/b2c/src/db/seed.ts b/mvp/b2c/src/db/seed.ts index 91a6085..a14b46b 100644 --- a/mvp/b2c/src/db/seed.ts +++ b/mvp/b2c/src/db/seed.ts @@ -17,6 +17,26 @@ const db = drizzle(client, { schema }); const euros = (n: number) => Math.round(n * 100); // a céntimos +// Factor de zona geográfica por provincia/ciudad. Las no listadas valen 1.0 (media nacional). +// Tramos: Madrid/Barcelona 1.40, islas 1.30, capitales grandes 1.20, rural/interior 0.85. +const ZONA_FACTORES: Record = Object.fromEntries( + [ + [['Madrid', 'Barcelona'], 1.4], + [ + ['Baleares', 'Islas Baleares', 'Palma', 'Mallorca', 'Las Palmas', 'Tenerife', 'Santa Cruz de Tenerife', 'Canarias'], + 1.3, + ], + [ + ['Valencia', 'Sevilla', 'Málaga', 'Bilbao', 'Vizcaya', 'Bizkaia', 'Zaragoza', 'Alicante', 'Murcia', 'San Sebastián', 'Gipuzkoa', 'Vitoria', 'Granada', 'Valladolid'], + 1.2, + ], + [ + ['Cuenca', 'Teruel', 'Soria', 'Zamora', 'Ávila', 'Palencia', 'Ourense', 'Lugo', 'Cáceres', 'Badajoz', 'Ciudad Real', 'Albacete', 'Jaén', 'Huesca', 'Segovia', 'Guadalajara'], + 0.85, + ], + ].flatMap(([nombres, factor]) => (nombres as string[]).map((n) => [n, factor as number])), +); + // Cada lead vive en un momento distinto del funnel para poder analizar // cuál es el siguiente paso de cada uno. days = hace cuántos días entró. type SeedLead = { @@ -513,8 +533,15 @@ async function main() { .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 }, + factorZona: ZONA_FACTORES, + manoObra: { + demolicion: 5000, + impermeabilizacion: 4500, + fontaneria: 14600, + electricidad: 5400, + mano_de_obra: 7500, + }, + extras: { tuberias: 115000, boletin: 17500, distribucion: 90000 }, }) .returning(); @@ -539,12 +566,12 @@ async function main() { }); const catalog = await db.insert(schema.catalogItems).values([ - cat('suelo', 'Gres cerámico básico', 'basica', 16, 'm2', 'suelo gres beige liso', 'SUE-B'), - cat('suelo', '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('suelo', 'Gres cerámico básico', 'basica', 40, 'm2', 'suelo gres beige liso', 'SUE-B'), + cat('suelo', 'Porcelánico símil madera', 'media', 70, 'm2', 'porcelánico símil roble claro', 'SUE-M'), + cat('suelo', 'Porcelánico gran formato', 'premium', 170, 'm2', 'porcelánico gran formato gris piedra', 'SUE-P'), + cat('pared', 'Azulejo blanco brillo', 'basica', 32, 'm2', 'azulejo blanco brillo 20x20', 'PAR-B'), + cat('pared', 'Azulejo rectificado', 'media', 60, 'm2', 'azulejo rectificado blanco mate 30x60', 'PAR-M'), + cat('pared', 'Porcelánico decorativo', 'premium', 140, 'm2', 'porcelánico decorativo símil mármol', 'PAR-P'), cat('pintura', 'Plástica mate', 'basica', 6, 'm2', 'pintura plástica blanca mate', 'PIN-B'), cat('pintura', 'Plástica 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'), diff --git a/mvp/b2c/src/lib/funnel/orchestrator.ts b/mvp/b2c/src/lib/funnel/orchestrator.ts index 0b884b6..4bc8d81 100644 --- a/mvp/b2c/src/lib/funnel/orchestrator.ts +++ b/mvp/b2c/src/lib/funnel/orchestrator.ts @@ -126,6 +126,8 @@ export async function procesarLead(leadId: string): Promise { m2Suelo: lead.m2Suelo ?? null, alturaTecho: lead.alturaTecho ?? null, provincia: lead.provincia ?? null, + anteriorA2000: lead.anteriorA2000, + cambioDistribucion: lead.cambioDistribucion, }); const result = applyPreferences(computeBudget(inputs, config, catalog), prefs); diff --git a/mvp/b2c/src/lib/voice/apply.ts b/mvp/b2c/src/lib/voice/apply.ts index 6d132b9..16c69ff 100644 --- a/mvp/b2c/src/lib/voice/apply.ts +++ b/mvp/b2c/src/lib/voice/apply.ts @@ -6,6 +6,8 @@ interface LeadInputsSource { m2Suelo: number | null; alturaTecho: number | null; provincia: string | null; + anteriorA2000?: boolean; + cambioDistribucion?: boolean; } export function mergeIntoBudgetInputs( @@ -20,6 +22,8 @@ export function mergeIntoBudgetInputs( estructural: prefs.estructural, provincia: lead.provincia, materialSelections: prefs.materialSelections, + anteriorA2000: lead.anteriorA2000 ?? false, + cambioDistribucion: lead.cambioDistribucion ?? false, }; } diff --git a/mvp/b2c/tests/budget/compute.test.ts b/mvp/b2c/tests/budget/compute.test.ts index 601c9d9..ef5cf0c 100644 --- a/mvp/b2c/tests/budget/compute.test.ts +++ b/mvp/b2c/tests/budget/compute.test.ts @@ -5,7 +5,7 @@ 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 }, + manoObra: { demolicion: 1500, impermeabilizacion: 0, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 }, }; const catalog: CatalogItem[] = [ @@ -102,3 +102,50 @@ describe('computeBudget', () => { expect(r.total).toBeGreaterThan(0); }); }); + +// Config con impermeabilización y extras fijos activos, sin factor de zona (total = subtotal). +const configFull: PricingConfig = { + alturaTechoDefault: 2.5, + factorZona: {}, + manoObra: { demolicion: 1500, impermeabilizacion: 2000, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 }, + extras: { tuberias: 100000, boletin: 15000, distribucion: 80000 }, +}; + +describe('computeBudget — impermeabilización y extras fijos', () => { + it('añade impermeabilización en zonas húmedas (baño) proporcional al suelo', () => { + const r = computeBudget(inputs({ tipoReforma: 'bano', m2Suelo: 5, provincia: null }), configFull, catalog); + const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe])); + expect(byKey.impermeabilizacion).toBe(10000); // 5 * 2000 + }); + + it('no añade impermeabilización en zonas secas (salón)', () => { + const r = computeBudget(inputs({ tipoReforma: 'salon', provincia: null }), configFull, catalog); + const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe])); + expect(byKey.impermeabilizacion).toBeUndefined(); + }); + + it('aplica siempre el boletín eléctrico como extra fijo', () => { + const r = computeBudget(inputs({ provincia: null }), configFull, catalog); + const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe])); + expect(byKey.extras_fijos).toBe(15000); // solo boletín + }); + + it('suma tuberías y distribución según los inputs del piso', () => { + const r = computeBudget( + inputs({ provincia: null, anteriorA2000: true, cambioDistribucion: true }), + configFull, + catalog, + ); + const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe])); + expect(byKey.extras_fijos).toBe(195000); // 15000 + 100000 + 80000 + }); + + it('la intensidad por tipo reduce la fontanería de un integral frente a una cocina', () => { + const cocina = computeBudget(inputs({ tipoReforma: 'cocina', m2Suelo: 16, provincia: null }), configFull, catalog); + const integral = computeBudget(inputs({ tipoReforma: 'integral', m2Suelo: 16, provincia: null }), configFull, catalog); + const fontCocina = cocina.partidas.find((p) => p.key === 'fontaneria')?.importe ?? 0; + const fontIntegral = integral.partidas.find((p) => p.key === 'fontaneria')?.importe ?? 0; + expect(fontCocina).toBe(19200); // 16 * 1200 * 1.0 + expect(fontIntegral).toBe(8640); // 16 * 1200 * 0.45 + }); +}); diff --git a/mvp/b2c/tests/budget/derive.test.ts b/mvp/b2c/tests/budget/derive.test.ts index f5efffd..af3f09a 100644 --- a/mvp/b2c/tests/budget/derive.test.ts +++ b/mvp/b2c/tests/budget/derive.test.ts @@ -5,7 +5,7 @@ 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 }, + manoObra: { demolicion: 0, impermeabilizacion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0 }, }; function inputs(partial: Partial): BudgetInputs {