diff --git a/mvp/b2c/src/budget/compute.ts b/mvp/b2c/src/budget/compute.ts new file mode 100644 index 0000000..f729df3 --- /dev/null +++ b/mvp/b2c/src/budget/compute.ts @@ -0,0 +1,108 @@ +import { deriveCantidades } from './derive'; +import { resolvePrecioUnitario } from './resolve'; +import { PARTIDA_LABEL, PARTIDA_ORDER } from './labels'; +import type { + BudgetInputs, + BudgetResult, + CategoriaMaterial, + CatalogItem, + PartidaKey, + PricingConfig, +} from './types'; + +const LICENCIA_MIN = 30000; // 300 € +const LICENCIA_MAX = 150000; // 1.500 € + +// A qué partida contribuye el material de cada categoría. +const MATERIAL_PARTIDA: Record = { + 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 = { + demolicion: 0, + alicatado: 0, + fontaneria: 0, + electricidad: 0, + carpinteria: 0, + mano_de_obra: 0, + extras: 0, + licencia: 0, + }; + + const cantidadPorCategoria: Record = { + 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); + } + + 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.mano_de_obra += Math.round(cant.m2Suelo * config.manoObra.mano_de_obra); + + 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 = (inputs.provincia && config.factorZona[inputs.provincia]) || 1; + const total = Math.round(subtotal * factorZona); + + const hasExact = Object.keys(inputs.materialSelections).length > 0; + 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 }; +} diff --git a/mvp/b2c/src/budget/index.ts b/mvp/b2c/src/budget/index.ts new file mode 100644 index 0000000..bce7b3b --- /dev/null +++ b/mvp/b2c/src/budget/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export { PARTIDA_LABEL, PARTIDA_ORDER } from './labels'; +export { deriveCantidades } from './derive'; +export { resolvePrecioUnitario } from './resolve'; +export { computeBudget } from './compute'; +// export { parseCatalogCsv } from './csv'; // added in Task 6 diff --git a/mvp/b2c/tests/budget/compute.test.ts b/mvp/b2c/tests/budget/compute.test.ts new file mode 100644 index 0000000..34ea6aa --- /dev/null +++ b/mvp/b2c/tests/budget/compute.test.ts @@ -0,0 +1,79 @@ +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, 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 { + 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); + }); +});