Files
reformix-hackaton/mvp/b2c/tests/budget/compute.test.ts
Goyo Cancio 2e3cd78216 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>
2026-06-04 14:02:57 +02:00

152 lines
7.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
});
});