feat: implement computeBudget with partidas, zona factor, licencia and range
This commit is contained in:
108
mvp/b2c/src/budget/compute.ts
Normal file
108
mvp/b2c/src/budget/compute.ts
Normal file
@@ -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<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,
|
||||
alicatado: 0,
|
||||
fontaneria: 0,
|
||||
electricidad: 0,
|
||||
carpinteria: 0,
|
||||
mano_de_obra: 0,
|
||||
extras: 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);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
6
mvp/b2c/src/budget/index.ts
Normal file
6
mvp/b2c/src/budget/index.ts
Normal file
@@ -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
|
||||
79
mvp/b2c/tests/budget/compute.test.ts
Normal file
79
mvp/b2c/tests/budget/compute.test.ts
Normal file
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user