Añade impermeabilización, extras fijos y zonas al motor de presupuesto

Acerca el cálculo a tarifas de mercado sin rehacer el modelo lineal €/m²:
- Impermeabilización como partida propia en zonas húmedas (cocina/baño/integral)
- Extras fijos que no escalan con m²: boletín (siempre), tuberías (piso anterior
  a 2000) y cambio de distribución (mover inodoro/ducha/bañera)
- Intensidad por tipo en fontanería/electricidad (baseline cocina) para que un
  integral no escale como un baño
- Factor de zona por provincia en tramos (Madrid/BCN 1.40, islas 1.30, capitales
  1.20, rural 0.85, resto 1.00)
- 2 preguntas nuevas en el formulario del cliente para disparar los extras
- Panel de precios: campo de impermeabilización + sección de extras fijos
- Seed recalibrado (mano de obra, extras, catálogo suelo/pared)
- Migración 0009 (leads.anterior_a_2000, leads.cambio_distribucion, pricing_config.extras)
- Tests del motor ampliados (impermeabilización, extras, intensidad por tipo)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Goyo Cancio
2026-06-04 14:02:57 +02:00
parent daa58c39a1
commit 2e3cd78216
18 changed files with 1941 additions and 18 deletions

View File

@@ -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<TipoReforma>(['cocina', 'bano', 'integral']);
// Intensidad de instalaciones (fontanería/electricidad) por m² según el tipo de reforma.
// Baseline cocina = 1.0. Un baño concentra más instalaciones por m²; un salón o un piso
// integral las diluye. Corrige el sesgo del modelo lineal €/m² sin rehacerlo.
const TIPO_INTENSIDAD: Record<TipoReforma, number> = {
cocina: 1.0,
bano: 1.3,
integral: 0.45,
salon: 0.4,
comedor: 0.4,
otro: 0.7,
};
// A qué partida contribuye el material de cada categoría.
const MATERIAL_PARTIDA: Record<CategoriaMaterial, PartidaKey> = {
suelo: 'alicatado',
@@ -34,12 +50,14 @@ export function computeBudget(
const importes: Record<PartidaKey, number> = {
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) => ({