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

@@ -21,11 +21,14 @@ export async function getEnvioMode(): Promise<EnvioMode> {
const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = {
demolicion: 0,
impermeabilizacion: 0,
fontaneria: 0,
electricidad: 0,
mano_de_obra: 0,
};
const EXTRAS_DEFAULT = { tuberias: 0, boletin: 0, distribucion: 0 };
export async function getPricingConfigFor(tenantId: string): Promise<PricingConfig> {
const [row] = await db
.select()
@@ -34,12 +37,18 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
.limit(1);
if (!row) {
return { alturaTechoDefault: 2.5, factorZona: {}, manoObra: { ...MANO_OBRA_DEFAULT } };
return {
alturaTechoDefault: 2.5,
factorZona: {},
manoObra: { ...MANO_OBRA_DEFAULT },
extras: { ...EXTRAS_DEFAULT },
};
}
return {
alturaTechoDefault: row.alturaTechoDefault,
factorZona: row.factorZona,
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
extras: { ...EXTRAS_DEFAULT, ...(row.extras ?? {}) },
};
}

View File

@@ -199,6 +199,9 @@ export const leads = pgTable(
alturaTecho: doublePrecision('altura_techo'),
calidadGlobal: calidad('calidad_global'),
estructural: boolean('estructural').notNull().default(false),
// Inputs de los extras fijos del presupuesto (no escalan con m²).
anteriorA2000: boolean('anterior_a_2000').notNull().default(false),
cambioDistribucion: boolean('cambio_distribucion').notNull().default(false),
materialSelections: jsonb('material_selections')
.$type<Record<string, string>>()
.notNull()
@@ -340,6 +343,11 @@ export const pricingConfig = pgTable('pricing_config', {
alturaTechoDefault: doublePrecision('altura_techo_default').notNull().default(2.5),
factorZona: jsonb('factor_zona').$type<Record<string, number>>().notNull().default({}),
manoObra: jsonb('mano_obra').$type<Record<string, number>>().notNull().default({}),
// Extras fijos en céntimos: { tuberias, boletin, distribucion }.
extras: jsonb('extras')
.$type<{ tuberias: number; boletin: number; distribucion: number }>()
.notNull()
.default({ tuberias: 0, boletin: 0, distribucion: 0 }),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

View File

@@ -17,6 +17,26 @@ const db = drizzle(client, { schema });
const euros = (n: number) => Math.round(n * 100); // a céntimos
// Factor de zona geográfica por provincia/ciudad. Las no listadas valen 1.0 (media nacional).
// Tramos: Madrid/Barcelona 1.40, islas 1.30, capitales grandes 1.20, rural/interior 0.85.
const ZONA_FACTORES: Record<string, number> = Object.fromEntries(
[
[['Madrid', 'Barcelona'], 1.4],
[
['Baleares', 'Islas Baleares', 'Palma', 'Mallorca', 'Las Palmas', 'Tenerife', 'Santa Cruz de Tenerife', 'Canarias'],
1.3,
],
[
['Valencia', 'Sevilla', 'Málaga', 'Bilbao', 'Vizcaya', 'Bizkaia', 'Zaragoza', 'Alicante', 'Murcia', 'San Sebastián', 'Gipuzkoa', 'Vitoria', 'Granada', 'Valladolid'],
1.2,
],
[
['Cuenca', 'Teruel', 'Soria', 'Zamora', 'Ávila', 'Palencia', 'Ourense', 'Lugo', 'Cáceres', 'Badajoz', 'Ciudad Real', 'Albacete', 'Jaén', 'Huesca', 'Segovia', 'Guadalajara'],
0.85,
],
].flatMap(([nombres, factor]) => (nombres as string[]).map((n) => [n, factor as number])),
);
// Cada lead vive en un momento distinto del funnel para poder analizar
// cuál es el siguiente paso de cada uno. days = hace cuántos días entró.
type SeedLead = {
@@ -513,8 +533,15 @@ async function main() {
.values({
tenantId: tenantRow.id,
alturaTechoDefault: 2.5,
factorZona: { Madrid: 1.1, Barcelona: 1.15, Valencia: 1.0, Sevilla: 0.95 },
manoObra: { demolicion: 1800, fontaneria: 2200, electricidad: 1600, mano_de_obra: 3500 },
factorZona: ZONA_FACTORES,
manoObra: {
demolicion: 5000,
impermeabilizacion: 4500,
fontaneria: 14600,
electricidad: 5400,
mano_de_obra: 7500,
},
extras: { tuberias: 115000, boletin: 17500, distribucion: 90000 },
})
.returning();
@@ -539,12 +566,12 @@ async function main() {
});
const catalog = await db.insert(schema.catalogItems).values([
cat('suelo', 'Gres cerámico básico', 'basica', 16, 'm2', 'suelo gres beige liso', 'SUE-B'),
cat('suelo', 'Porcelánico símil madera', 'media', 28, 'm2', 'porcelánico símil roble claro', 'SUE-M'),
cat('suelo', 'Porcelánico gran formato', 'premium', 48, 'm2', 'porcelánico gran formato gris piedra', 'SUE-P'),
cat('pared', 'Azulejo blanco brillo', 'basica', 14, 'm2', 'azulejo blanco brillo 20x20', 'PAR-B'),
cat('pared', 'Azulejo rectificado', 'media', 24, 'm2', 'azulejo rectificado blanco mate 30x60', 'PAR-M'),
cat('pared', 'Porcelánico decorativo', 'premium', 42, 'm2', 'porcelánico decorativo símil mármol', 'PAR-P'),
cat('suelo', 'Gres cerámico básico', 'basica', 40, 'm2', 'suelo gres beige liso', 'SUE-B'),
cat('suelo', 'Porcelánico símil madera', 'media', 70, 'm2', 'porcelánico símil roble claro', 'SUE-M'),
cat('suelo', 'Porcelánico gran formato', 'premium', 170, 'm2', 'porcelánico gran formato gris piedra', 'SUE-P'),
cat('pared', 'Azulejo blanco brillo', 'basica', 32, 'm2', 'azulejo blanco brillo 20x20', 'PAR-B'),
cat('pared', 'Azulejo rectificado', 'media', 60, 'm2', 'azulejo rectificado blanco mate 30x60', 'PAR-M'),
cat('pared', 'Porcelánico decorativo', 'premium', 140, 'm2', 'porcelánico decorativo símil mármol', 'PAR-P'),
cat('pintura', 'Plástica mate', 'basica', 6, 'm2', 'pintura plástica blanca mate', 'PIN-B'),
cat('pintura', 'Plástica lavable', 'media', 9, 'm2', 'pintura lavable blanco roto', 'PIN-M'),
cat('pintura', 'Esmalte premium', 'premium', 14, 'm2', 'esmalte al agua acabado seda gris perla', 'PIN-P'),