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>
This commit is contained in:
Goyo Cancio
2026-06-04 14:02:57 +02:00
parent daa58c39a1
commit 2e3cd78216
18 changed files with 1941 additions and 18 deletions

View File

@@ -66,6 +66,7 @@ export async function actualizarConfig(formData: FormData) {
alturaTechoDefault: parsePositive(formData.get('alturaTechoDefault'), 'altura de techo'),
manoObra: {
demolicion: eurosToCents(formData.get('mo_demolicion'), 'demolición'),
impermeabilizacion: eurosToCents(formData.get('mo_impermeabilizacion'), 'impermeabilización'),
fontaneria: eurosToCents(formData.get('mo_fontaneria'), 'fontanería'),
electricidad: eurosToCents(formData.get('mo_electricidad'), 'electricidad'),
mano_de_obra: eurosToCents(formData.get('mo_mano_de_obra'), 'mano de obra'),
@@ -76,6 +77,22 @@ export async function actualizarConfig(formData: FormData) {
revalidatePath('/panel/precios');
}
export async function actualizarExtras(formData: FormData) {
const tenantId = await getTenantId();
await db
.update(pricingConfig)
.set({
extras: {
tuberias: eurosToCents(formData.get('extra_tuberias'), 'renovación de tuberías'),
boletin: eurosToCents(formData.get('extra_boletin'), 'boletín eléctrico'),
distribucion: eurosToCents(formData.get('extra_distribucion'), 'cambio de distribución'),
},
updatedAt: new Date(),
})
.where(eq(pricingConfig.tenantId, tenantId));
revalidatePath('/panel/precios');
}
export async function actualizarEnvio(formData: FormData) {
const tenantId = await getTenantId();
const modo = formData.get('modo');

View File

@@ -4,6 +4,7 @@ import {
actualizarPrecio,
borrarMaterial,
actualizarConfig,
actualizarExtras,
actualizarEnvio,
importarCatalogoCsv,
} from './actions';
@@ -96,6 +97,7 @@ export default async function PreciosPage() {
{(
[
['demolicion', 'Demolición'],
['impermeabilizacion', 'Impermeabilización'],
['fontaneria', 'Fontanería'],
['electricidad', 'Electricidad'],
['mano_de_obra', 'Mano de obra'],
@@ -118,6 +120,39 @@ export default async function PreciosPage() {
</form>
</section>
{/* Extras fijos */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-1">Extras fijos</h2>
<p className="text-sm text-gray-500 mb-4">
Importes fijos que no escalan con los metros. El boletín eléctrico se aplica siempre; las
tuberías solo en pisos anteriores al año 2000 y la distribución al mover inodoro, ducha o
bañera.
</p>
<form action={actualizarExtras} className="grid grid-cols-2 md:grid-cols-3 gap-3 items-end">
{(
[
['tuberias', 'Renovación de tuberías'],
['boletin', 'Boletín eléctrico'],
['distribucion', 'Cambio de distribución'],
] as const
).map(([k, etiqueta]) => (
<label key={k} className="text-sm">
<span className="block text-xs text-gray-500 mb-1">{etiqueta} ()</span>
<input
name={`extra_${k}`}
type="number"
step="0.01"
defaultValue={(config.extras?.[k] ?? 0) / 100}
className="w-full border border-gray-300 rounded-lg px-2 py-1.5"
/>
</label>
))}
<button className="col-span-2 md:col-span-3 justify-self-start bg-primary-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar extras
</button>
</form>
</section>
{/* Catálogo por categoría */}
{CATEGORIAS.map((categoria) => {
const items = catalog.filter((c) => c.categoria === categoria);

View File

@@ -146,6 +146,8 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
const presupuestoTarget =
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
const estructural = formData.get('estructural') === 'on';
const anteriorA2000 = formData.get('anteriorA2000') === 'on';
const cambioDistribucion = formData.get('cambioDistribucion') === 'on';
let zonas = await parsearZonas(formData);
if (zonas.length === 0) {
@@ -191,6 +193,8 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
urgencia,
presupuestoTarget,
estructural,
anteriorA2000,
cambioDistribucion,
tasteText,
pipelineStage: 'fotos_subidas',
updatedAt: new Date(),