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>
152 lines
7.4 KiB
TypeScript
152 lines
7.4 KiB
TypeScript
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, impermeabilizacion: 0, 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);
|
||
});
|
||
});
|
||
|
||
// 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
|
||
});
|
||
});
|