From 405fdc4e322f9cd5bf2986a529154cefd3d8b794 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Sun, 31 May 2026 16:15:13 +0200 Subject: [PATCH] =?UTF-8?q?Add=20merge=20a=20BudgetInputs=20y=20aplicaci?= =?UTF-8?q?=C3=B3n=20de=20extras/ajustes=20al=20presupuesto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mvp/b2c/src/lib/voice/apply.ts | 56 ++++++++++++++++++++++++ mvp/b2c/tests/voice/apply.test.ts | 72 +++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 mvp/b2c/src/lib/voice/apply.ts create mode 100644 mvp/b2c/tests/voice/apply.test.ts diff --git a/mvp/b2c/src/lib/voice/apply.ts b/mvp/b2c/src/lib/voice/apply.ts new file mode 100644 index 0000000..6d132b9 --- /dev/null +++ b/mvp/b2c/src/lib/voice/apply.ts @@ -0,0 +1,56 @@ +import type { BudgetInputs, BudgetResult, PartidaResult, TipoReforma } from '@/budget/types'; +import type { AbstractedPreferences } from './preferences'; + +interface LeadInputsSource { + tipoReforma: TipoReforma | null; + m2Suelo: number | null; + alturaTecho: number | null; + provincia: string | null; +} + +export function mergeIntoBudgetInputs( + prefs: AbstractedPreferences, + lead: LeadInputsSource, +): BudgetInputs { + return { + tipoReforma: lead.tipoReforma ?? 'otro', + m2Suelo: lead.m2Suelo, + alturaTecho: lead.alturaTecho, + calidadGlobal: prefs.calidadGlobal, + estructural: prefs.estructural, + provincia: lead.provincia, + materialSelections: prefs.materialSelections, + }; +} + +export function applyPreferences( + result: BudgetResult, + prefs: AbstractedPreferences, +): BudgetResult { + const extras: PartidaResult[] = prefs.elementos.map((e) => ({ + key: e.key, + label: e.label, + importe: e.importe, + })); + + prefs.ajustes.forEach((a, i) => { + const importe = + a.tipo === 'fijo' ? a.valor : Math.round(result.subtotal * (a.valor - 1)); + if (importe === 0) return; + extras.push({ key: `ajuste_${i}`, label: a.label, importe }); + }); + + if (extras.length === 0 && prefs.estiloRender.length === 0) return result; + + const partidas = [...result.partidas, ...extras]; + const subtotal = partidas.reduce((s, p) => s + p.importe, 0); + const total = Math.round(subtotal * result.factorZona); + const scale = result.total > 0 ? total / result.total : 1; + const rango = { + min: Math.round(result.rango.min * scale), + max: Math.round(result.rango.max * scale), + }; + const materialesRender = [...result.materialesRender, ...prefs.estiloRender]; + + return { ...result, partidas, subtotal, total, rango, materialesRender }; +} diff --git a/mvp/b2c/tests/voice/apply.test.ts b/mvp/b2c/tests/voice/apply.test.ts new file mode 100644 index 0000000..1a74d22 --- /dev/null +++ b/mvp/b2c/tests/voice/apply.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { applyPreferences, mergeIntoBudgetInputs } from '@/lib/voice/apply'; +import type { AbstractedPreferences } from '@/lib/voice/preferences'; +import type { BudgetResult } from '@/budget/types'; + +function prefs(partial: Partial): AbstractedPreferences { + return { + calidadGlobal: 'media', + materialSelections: {}, + estructural: false, + urgencia: null, + presupuestoTarget: null, + elementos: [], + estiloRender: [], + ajustes: [], + confianza: 'media', + resumen: '', + camposFaltantes: [], + ...partial, + }; +} + +const base: BudgetResult = { + partidas: [{ key: 'mano_de_obra', label: 'Mano de obra', importe: 100000 }], + subtotal: 100000, + factorZona: 1, + total: 100000, + rango: { min: 85000, max: 115000 }, + confianza: 'media', + materialesRender: ['suelo cerámico gris'], + avisos: [], +}; + +describe('applyPreferences', () => { + it('añade elementos y ajustes fijos como partidas y recalcula totales', () => { + const r = applyPreferences(base, prefs({ + elementos: [{ key: 'isla_cocina', label: 'Isla de cocina', importe: 120000 }], + ajustes: [{ label: 'Encimera de piedra natural', tipo: 'fijo', valor: 50000, motivo: 'cuarzo' }], + estiloRender: ['estilo nórdico'], + })); + const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe])); + expect(byKey.isla_cocina).toBe(120000); + expect(r.partidas.some((p) => p.label === 'Encimera de piedra natural' && p.importe === 50000)).toBe(true); + expect(r.subtotal).toBe(270000); + expect(r.total).toBe(270000); + expect(r.rango.min).toBe(229500); // round(85000 * 2.7) + expect(r.rango.max).toBe(310500); // round(115000 * 2.7) + expect(r.materialesRender).toContain('estilo nórdico'); + }); + + it('un ajuste tipo factor se aplica sobre el subtotal original como partida explícita', () => { + const r = applyPreferences(base, prefs({ + ajustes: [{ label: 'Acabado premium global', tipo: 'factor', valor: 1.1, motivo: 'premium' }], + })); + expect(r.partidas.some((p) => p.label === 'Acabado premium global' && p.importe === 10000)).toBe(true); + expect(r.subtotal).toBe(110000); + }); +}); + +describe('mergeIntoBudgetInputs', () => { + it('vuelca preferencias y datos del lead en BudgetInputs', () => { + const inputs = mergeIntoBudgetInputs( + prefs({ calidadGlobal: 'premium', materialSelections: { suelo: 'suelo-p' }, estructural: true }), + { tipoReforma: 'cocina', m2Suelo: 16, alturaTecho: null, provincia: 'Madrid' }, + ); + expect(inputs.calidadGlobal).toBe('premium'); + expect(inputs.materialSelections.suelo).toBe('suelo-p'); + expect(inputs.estructural).toBe(true); + expect(inputs.m2Suelo).toBe(16); + expect(inputs.provincia).toBe('Madrid'); + }); +});