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>
143 lines
4.5 KiB
TypeScript
143 lines
4.5 KiB
TypeScript
import { deriveCantidades } from './derive';
|
|
import { resolvePrecioUnitario } from './resolve';
|
|
import { PARTIDA_LABEL, PARTIDA_ORDER } from './labels';
|
|
import type {
|
|
BudgetInputs,
|
|
BudgetResult,
|
|
CategoriaMaterial,
|
|
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',
|
|
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,
|
|
impermeabilizacion: 0,
|
|
alicatado: 0,
|
|
fontaneria: 0,
|
|
electricidad: 0,
|
|
carpinteria: 0,
|
|
mano_de_obra: 0,
|
|
extras: 0,
|
|
extras_fijos: 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);
|
|
}
|
|
|
|
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 * 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) => ({
|
|
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 };
|
|
}
|