feat: implement computeBudget with partidas, zona factor, licencia and range

This commit is contained in:
Carlos Narro
2026-05-30 12:22:42 +02:00
parent 61e0f5dbe5
commit 896c7ac89b
3 changed files with 193 additions and 0 deletions

View 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 };
}

View 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