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:
3
mvp/b2c/.gitignore
vendored
3
mvp/b2c/.gitignore
vendored
@@ -43,3 +43,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
# Colección Postman con la FUNNEL_API_KEY embebida — no commitear
|
# Colección Postman con la FUNNEL_API_KEY embebida — no commitear
|
||||||
api-docs/reformix-ingesta.postman_collection.json
|
api-docs/reformix-ingesta.postman_collection.json
|
||||||
|
|
||||||
|
# Logs locales del dev server
|
||||||
|
dev.log
|
||||||
|
|||||||
3
mvp/b2c/drizzle/0009_white_agent_brand.sql
Normal file
3
mvp/b2c/drizzle/0009_white_agent_brand.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "leads" ADD COLUMN "anterior_a_2000" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "leads" ADD COLUMN "cambio_distribucion" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "pricing_config" ADD COLUMN "extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::jsonb NOT NULL;
|
||||||
1694
mvp/b2c/drizzle/meta/0009_snapshot.json
Normal file
1694
mvp/b2c/drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,13 @@
|
|||||||
"when": 1780505942614,
|
"when": 1780505942614,
|
||||||
"tag": "0008_sharp_bloodaxe",
|
"tag": "0008_sharp_bloodaxe",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780569557328,
|
||||||
|
"tag": "0009_white_agent_brand",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,7 @@ export async function actualizarConfig(formData: FormData) {
|
|||||||
alturaTechoDefault: parsePositive(formData.get('alturaTechoDefault'), 'altura de techo'),
|
alturaTechoDefault: parsePositive(formData.get('alturaTechoDefault'), 'altura de techo'),
|
||||||
manoObra: {
|
manoObra: {
|
||||||
demolicion: eurosToCents(formData.get('mo_demolicion'), 'demolición'),
|
demolicion: eurosToCents(formData.get('mo_demolicion'), 'demolición'),
|
||||||
|
impermeabilizacion: eurosToCents(formData.get('mo_impermeabilizacion'), 'impermeabilización'),
|
||||||
fontaneria: eurosToCents(formData.get('mo_fontaneria'), 'fontanería'),
|
fontaneria: eurosToCents(formData.get('mo_fontaneria'), 'fontanería'),
|
||||||
electricidad: eurosToCents(formData.get('mo_electricidad'), 'electricidad'),
|
electricidad: eurosToCents(formData.get('mo_electricidad'), 'electricidad'),
|
||||||
mano_de_obra: eurosToCents(formData.get('mo_mano_de_obra'), 'mano de obra'),
|
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');
|
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) {
|
export async function actualizarEnvio(formData: FormData) {
|
||||||
const tenantId = await getTenantId();
|
const tenantId = await getTenantId();
|
||||||
const modo = formData.get('modo');
|
const modo = formData.get('modo');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
actualizarPrecio,
|
actualizarPrecio,
|
||||||
borrarMaterial,
|
borrarMaterial,
|
||||||
actualizarConfig,
|
actualizarConfig,
|
||||||
|
actualizarExtras,
|
||||||
actualizarEnvio,
|
actualizarEnvio,
|
||||||
importarCatalogoCsv,
|
importarCatalogoCsv,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
@@ -96,6 +97,7 @@ export default async function PreciosPage() {
|
|||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
['demolicion', 'Demolición'],
|
['demolicion', 'Demolición'],
|
||||||
|
['impermeabilizacion', 'Impermeabilización'],
|
||||||
['fontaneria', 'Fontanería'],
|
['fontaneria', 'Fontanería'],
|
||||||
['electricidad', 'Electricidad'],
|
['electricidad', 'Electricidad'],
|
||||||
['mano_de_obra', 'Mano de obra'],
|
['mano_de_obra', 'Mano de obra'],
|
||||||
@@ -118,6 +120,39 @@ export default async function PreciosPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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 */}
|
{/* Catálogo por categoría */}
|
||||||
{CATEGORIAS.map((categoria) => {
|
{CATEGORIAS.map((categoria) => {
|
||||||
const items = catalog.filter((c) => c.categoria === categoria);
|
const items = catalog.filter((c) => c.categoria === categoria);
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
|
|||||||
const presupuestoTarget =
|
const presupuestoTarget =
|
||||||
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
|
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
|
||||||
const estructural = formData.get('estructural') === 'on';
|
const estructural = formData.get('estructural') === 'on';
|
||||||
|
const anteriorA2000 = formData.get('anteriorA2000') === 'on';
|
||||||
|
const cambioDistribucion = formData.get('cambioDistribucion') === 'on';
|
||||||
|
|
||||||
let zonas = await parsearZonas(formData);
|
let zonas = await parsearZonas(formData);
|
||||||
if (zonas.length === 0) {
|
if (zonas.length === 0) {
|
||||||
@@ -191,6 +193,8 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
|
|||||||
urgencia,
|
urgencia,
|
||||||
presupuestoTarget,
|
presupuestoTarget,
|
||||||
estructural,
|
estructural,
|
||||||
|
anteriorA2000,
|
||||||
|
cambioDistribucion,
|
||||||
tasteText,
|
tasteText,
|
||||||
pipelineStage: 'fotos_subidas',
|
pipelineStage: 'fotos_subidas',
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|||||||
@@ -8,11 +8,27 @@ import type {
|
|||||||
CatalogItem,
|
CatalogItem,
|
||||||
PartidaKey,
|
PartidaKey,
|
||||||
PricingConfig,
|
PricingConfig,
|
||||||
|
TipoReforma,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const LICENCIA_MIN = 30000; // 300 €
|
const LICENCIA_MIN = 30000; // 300 €
|
||||||
const LICENCIA_MAX = 150000; // 1.500 €
|
const LICENCIA_MAX = 150000; // 1.500 €
|
||||||
|
|
||||||
|
// Zonas húmedas: las únicas que llevan impermeabilización.
|
||||||
|
const WET = new Set<TipoReforma>(['cocina', 'bano', 'integral']);
|
||||||
|
|
||||||
|
// Intensidad de instalaciones (fontanería/electricidad) por m² según el tipo de reforma.
|
||||||
|
// Baseline cocina = 1.0. Un baño concentra más instalaciones por m²; un salón o un piso
|
||||||
|
// integral las diluye. Corrige el sesgo del modelo lineal €/m² sin rehacerlo.
|
||||||
|
const TIPO_INTENSIDAD: Record<TipoReforma, number> = {
|
||||||
|
cocina: 1.0,
|
||||||
|
bano: 1.3,
|
||||||
|
integral: 0.45,
|
||||||
|
salon: 0.4,
|
||||||
|
comedor: 0.4,
|
||||||
|
otro: 0.7,
|
||||||
|
};
|
||||||
|
|
||||||
// A qué partida contribuye el material de cada categoría.
|
// A qué partida contribuye el material de cada categoría.
|
||||||
const MATERIAL_PARTIDA: Record<CategoriaMaterial, PartidaKey> = {
|
const MATERIAL_PARTIDA: Record<CategoriaMaterial, PartidaKey> = {
|
||||||
suelo: 'alicatado',
|
suelo: 'alicatado',
|
||||||
@@ -34,12 +50,14 @@ export function computeBudget(
|
|||||||
|
|
||||||
const importes: Record<PartidaKey, number> = {
|
const importes: Record<PartidaKey, number> = {
|
||||||
demolicion: 0,
|
demolicion: 0,
|
||||||
|
impermeabilizacion: 0,
|
||||||
alicatado: 0,
|
alicatado: 0,
|
||||||
fontaneria: 0,
|
fontaneria: 0,
|
||||||
electricidad: 0,
|
electricidad: 0,
|
||||||
carpinteria: 0,
|
carpinteria: 0,
|
||||||
mano_de_obra: 0,
|
mano_de_obra: 0,
|
||||||
extras: 0,
|
extras: 0,
|
||||||
|
extras_fijos: 0,
|
||||||
licencia: 0,
|
licencia: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,11 +85,25 @@ export function computeBudget(
|
|||||||
if (item.descriptorRender) materialesRender.push(item.descriptorRender);
|
if (item.descriptorRender) materialesRender.push(item.descriptorRender);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const intensidad = TIPO_INTENSIDAD[inputs.tipoReforma] ?? 1;
|
||||||
importes.demolicion += Math.round(cant.m2Suelo * config.manoObra.demolicion);
|
importes.demolicion += Math.round(cant.m2Suelo * config.manoObra.demolicion);
|
||||||
importes.fontaneria += Math.round(cant.m2Suelo * config.manoObra.fontaneria);
|
importes.fontaneria += Math.round(cant.m2Suelo * config.manoObra.fontaneria * intensidad);
|
||||||
importes.electricidad += Math.round(cant.m2Suelo * config.manoObra.electricidad);
|
importes.electricidad += Math.round(cant.m2Suelo * config.manoObra.electricidad * intensidad);
|
||||||
importes.mano_de_obra += Math.round(cant.m2Suelo * config.manoObra.mano_de_obra);
|
importes.mano_de_obra += Math.round(cant.m2Suelo * config.manoObra.mano_de_obra);
|
||||||
|
|
||||||
|
// Impermeabilización: solo en zonas húmedas, proporcional al suelo a tratar.
|
||||||
|
if (WET.has(inputs.tipoReforma)) {
|
||||||
|
importes.impermeabilizacion += Math.round(cant.m2Suelo * config.manoObra.impermeabilizacion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extras fijos (no escalan con m²). El boletín eléctrico es siempre obligatorio.
|
||||||
|
const extras = config.extras;
|
||||||
|
if (extras) {
|
||||||
|
importes.extras_fijos += extras.boletin;
|
||||||
|
if (inputs.anteriorA2000) importes.extras_fijos += extras.tuberias;
|
||||||
|
if (inputs.cambioDistribucion) importes.extras_fijos += extras.distribucion;
|
||||||
|
}
|
||||||
|
|
||||||
if (inputs.estructural) importes.licencia += LICENCIA_MIN;
|
if (inputs.estructural) importes.licencia += LICENCIA_MIN;
|
||||||
|
|
||||||
const partidas = PARTIDA_ORDER.filter((k) => importes[k] > 0).map((k) => ({
|
const partidas = PARTIDA_ORDER.filter((k) => importes[k] > 0).map((k) => ({
|
||||||
|
|||||||
@@ -2,22 +2,26 @@ import type { PartidaKey } from './types';
|
|||||||
|
|
||||||
export const PARTIDA_ORDER: PartidaKey[] = [
|
export const PARTIDA_ORDER: PartidaKey[] = [
|
||||||
'demolicion',
|
'demolicion',
|
||||||
|
'impermeabilizacion',
|
||||||
'alicatado',
|
'alicatado',
|
||||||
'fontaneria',
|
'fontaneria',
|
||||||
'electricidad',
|
'electricidad',
|
||||||
'carpinteria',
|
'carpinteria',
|
||||||
'mano_de_obra',
|
'mano_de_obra',
|
||||||
'extras',
|
'extras',
|
||||||
|
'extras_fijos',
|
||||||
'licencia',
|
'licencia',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PARTIDA_LABEL: Record<PartidaKey, string> = {
|
export const PARTIDA_LABEL: Record<PartidaKey, string> = {
|
||||||
demolicion: 'Demolición',
|
demolicion: 'Demolición',
|
||||||
|
impermeabilizacion: 'Impermeabilización',
|
||||||
alicatado: 'Alicatado y solado',
|
alicatado: 'Alicatado y solado',
|
||||||
fontaneria: 'Fontanería',
|
fontaneria: 'Fontanería',
|
||||||
electricidad: 'Electricidad',
|
electricidad: 'Electricidad',
|
||||||
carpinteria: 'Carpintería y mobiliario',
|
carpinteria: 'Carpintería y mobiliario',
|
||||||
mano_de_obra: 'Mano de obra',
|
mano_de_obra: 'Mano de obra',
|
||||||
extras: 'Pintura y extras',
|
extras: 'Pintura y extras',
|
||||||
|
extras_fijos: 'Extras (tuberías, boletín, distribución)',
|
||||||
licencia: 'Licencia + Proyecto técnico',
|
licencia: 'Licencia + Proyecto técnico',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,15 +5,22 @@ export type TipoReforma = 'cocina' | 'bano' | 'salon' | 'comedor' | 'integral' |
|
|||||||
|
|
||||||
export type PartidaKey =
|
export type PartidaKey =
|
||||||
| 'demolicion'
|
| 'demolicion'
|
||||||
|
| 'impermeabilizacion'
|
||||||
| 'alicatado'
|
| 'alicatado'
|
||||||
| 'fontaneria'
|
| 'fontaneria'
|
||||||
| 'electricidad'
|
| 'electricidad'
|
||||||
| 'carpinteria'
|
| 'carpinteria'
|
||||||
| 'mano_de_obra'
|
| 'mano_de_obra'
|
||||||
| 'extras'
|
| 'extras'
|
||||||
|
| 'extras_fijos'
|
||||||
| 'licencia';
|
| 'licencia';
|
||||||
|
|
||||||
export type ManoObraKey = 'demolicion' | 'fontaneria' | 'electricidad' | 'mano_de_obra';
|
export type ManoObraKey =
|
||||||
|
| 'demolicion'
|
||||||
|
| 'impermeabilizacion'
|
||||||
|
| 'fontaneria'
|
||||||
|
| 'electricidad'
|
||||||
|
| 'mano_de_obra';
|
||||||
|
|
||||||
export interface CatalogItem {
|
export interface CatalogItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,10 +34,18 @@ export interface CatalogItem {
|
|||||||
sku: string;
|
sku: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extras fijos que no escalan con los m² (céntimos). Se aplican según el estado del piso.
|
||||||
|
export interface ExtrasFijos {
|
||||||
|
tuberias: number; // renovación de tuberías (pisos anteriores a 2000)
|
||||||
|
boletin: number; // boletín eléctrico (siempre obligatorio)
|
||||||
|
distribucion: number; // cambio de distribución (mover inodoro/ducha/bañera)
|
||||||
|
}
|
||||||
|
|
||||||
export interface PricingConfig {
|
export interface PricingConfig {
|
||||||
alturaTechoDefault: number; // metros
|
alturaTechoDefault: number; // metros
|
||||||
factorZona: Record<string, number>; // provincia -> multiplicador
|
factorZona: Record<string, number>; // provincia -> multiplicador
|
||||||
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
|
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
|
||||||
|
extras?: ExtrasFijos; // importes fijos en céntimos
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BudgetInputs {
|
export interface BudgetInputs {
|
||||||
@@ -41,6 +56,8 @@ export interface BudgetInputs {
|
|||||||
estructural: boolean;
|
estructural: boolean;
|
||||||
provincia: string | null;
|
provincia: string | null;
|
||||||
materialSelections: Partial<Record<CategoriaMaterial, string>>; // categoria -> catalogItemId
|
materialSelections: Partial<Record<CategoriaMaterial, string>>; // categoria -> catalogItemId
|
||||||
|
anteriorA2000?: boolean; // dispara el extra de renovación de tuberías
|
||||||
|
cambioDistribucion?: boolean; // dispara el extra de cambio de distribución
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartidaResult {
|
export interface PartidaResult {
|
||||||
|
|||||||
@@ -271,10 +271,20 @@ export default function FormularioZonas({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
|
<div className="flex flex-col gap-3">
|
||||||
<input type="checkbox" name="estructural" className="w-4 h-4 accent-black" />
|
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
|
||||||
Hay que mover sanitarios, tirar algún muro o cambiar la distribución
|
<input type="checkbox" name="estructural" className="w-4 h-4 accent-black" />
|
||||||
</label>
|
Hay que tirar algún muro u obra estructural
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
|
||||||
|
<input type="checkbox" name="cambioDistribucion" className="w-4 h-4 accent-black" />
|
||||||
|
Hay que mover el inodoro, la ducha o la bañera
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
|
||||||
|
<input type="checkbox" name="anteriorA2000" className="w-4 h-4 accent-black" />
|
||||||
|
El piso es anterior al año 2000
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubmitButton />
|
<SubmitButton />
|
||||||
|
|||||||
@@ -21,11 +21,14 @@ export async function getEnvioMode(): Promise<EnvioMode> {
|
|||||||
|
|
||||||
const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = {
|
const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = {
|
||||||
demolicion: 0,
|
demolicion: 0,
|
||||||
|
impermeabilizacion: 0,
|
||||||
fontaneria: 0,
|
fontaneria: 0,
|
||||||
electricidad: 0,
|
electricidad: 0,
|
||||||
mano_de_obra: 0,
|
mano_de_obra: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EXTRAS_DEFAULT = { tuberias: 0, boletin: 0, distribucion: 0 };
|
||||||
|
|
||||||
export async function getPricingConfigFor(tenantId: string): Promise<PricingConfig> {
|
export async function getPricingConfigFor(tenantId: string): Promise<PricingConfig> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -34,12 +37,18 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return { alturaTechoDefault: 2.5, factorZona: {}, manoObra: { ...MANO_OBRA_DEFAULT } };
|
return {
|
||||||
|
alturaTechoDefault: 2.5,
|
||||||
|
factorZona: {},
|
||||||
|
manoObra: { ...MANO_OBRA_DEFAULT },
|
||||||
|
extras: { ...EXTRAS_DEFAULT },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
alturaTechoDefault: row.alturaTechoDefault,
|
alturaTechoDefault: row.alturaTechoDefault,
|
||||||
factorZona: row.factorZona,
|
factorZona: row.factorZona,
|
||||||
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
|
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
|
||||||
|
extras: { ...EXTRAS_DEFAULT, ...(row.extras ?? {}) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ export const leads = pgTable(
|
|||||||
alturaTecho: doublePrecision('altura_techo'),
|
alturaTecho: doublePrecision('altura_techo'),
|
||||||
calidadGlobal: calidad('calidad_global'),
|
calidadGlobal: calidad('calidad_global'),
|
||||||
estructural: boolean('estructural').notNull().default(false),
|
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')
|
materialSelections: jsonb('material_selections')
|
||||||
.$type<Record<string, string>>()
|
.$type<Record<string, string>>()
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -340,6 +343,11 @@ export const pricingConfig = pgTable('pricing_config', {
|
|||||||
alturaTechoDefault: doublePrecision('altura_techo_default').notNull().default(2.5),
|
alturaTechoDefault: doublePrecision('altura_techo_default').notNull().default(2.5),
|
||||||
factorZona: jsonb('factor_zona').$type<Record<string, number>>().notNull().default({}),
|
factorZona: jsonb('factor_zona').$type<Record<string, number>>().notNull().default({}),
|
||||||
manoObra: jsonb('mano_obra').$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(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,26 @@ const db = drizzle(client, { schema });
|
|||||||
|
|
||||||
const euros = (n: number) => Math.round(n * 100); // a céntimos
|
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
|
// 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ó.
|
// cuál es el siguiente paso de cada uno. days = hace cuántos días entró.
|
||||||
type SeedLead = {
|
type SeedLead = {
|
||||||
@@ -513,8 +533,15 @@ async function main() {
|
|||||||
.values({
|
.values({
|
||||||
tenantId: tenantRow.id,
|
tenantId: tenantRow.id,
|
||||||
alturaTechoDefault: 2.5,
|
alturaTechoDefault: 2.5,
|
||||||
factorZona: { Madrid: 1.1, Barcelona: 1.15, Valencia: 1.0, Sevilla: 0.95 },
|
factorZona: ZONA_FACTORES,
|
||||||
manoObra: { demolicion: 1800, fontaneria: 2200, electricidad: 1600, mano_de_obra: 3500 },
|
manoObra: {
|
||||||
|
demolicion: 5000,
|
||||||
|
impermeabilizacion: 4500,
|
||||||
|
fontaneria: 14600,
|
||||||
|
electricidad: 5400,
|
||||||
|
mano_de_obra: 7500,
|
||||||
|
},
|
||||||
|
extras: { tuberias: 115000, boletin: 17500, distribucion: 90000 },
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -539,12 +566,12 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const catalog = await db.insert(schema.catalogItems).values([
|
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', 'Gres cerámico básico', 'basica', 40, '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 símil madera', 'media', 70, '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('suelo', 'Porcelánico gran formato', 'premium', 170, '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 blanco brillo', 'basica', 32, 'm2', 'azulejo blanco brillo 20x20', 'PAR-B'),
|
||||||
cat('pared', 'Azulejo rectificado', 'media', 24, 'm2', 'azulejo rectificado blanco mate 30x60', 'PAR-M'),
|
cat('pared', 'Azulejo rectificado', 'media', 60, '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('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 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', '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'),
|
cat('pintura', 'Esmalte premium', 'premium', 14, 'm2', 'esmalte al agua acabado seda gris perla', 'PIN-P'),
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export async function procesarLead(leadId: string): Promise<void> {
|
|||||||
m2Suelo: lead.m2Suelo ?? null,
|
m2Suelo: lead.m2Suelo ?? null,
|
||||||
alturaTecho: lead.alturaTecho ?? null,
|
alturaTecho: lead.alturaTecho ?? null,
|
||||||
provincia: lead.provincia ?? null,
|
provincia: lead.provincia ?? null,
|
||||||
|
anteriorA2000: lead.anteriorA2000,
|
||||||
|
cambioDistribucion: lead.cambioDistribucion,
|
||||||
});
|
});
|
||||||
const result = applyPreferences(computeBudget(inputs, config, catalog), prefs);
|
const result = applyPreferences(computeBudget(inputs, config, catalog), prefs);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ interface LeadInputsSource {
|
|||||||
m2Suelo: number | null;
|
m2Suelo: number | null;
|
||||||
alturaTecho: number | null;
|
alturaTecho: number | null;
|
||||||
provincia: string | null;
|
provincia: string | null;
|
||||||
|
anteriorA2000?: boolean;
|
||||||
|
cambioDistribucion?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeIntoBudgetInputs(
|
export function mergeIntoBudgetInputs(
|
||||||
@@ -20,6 +22,8 @@ export function mergeIntoBudgetInputs(
|
|||||||
estructural: prefs.estructural,
|
estructural: prefs.estructural,
|
||||||
provincia: lead.provincia,
|
provincia: lead.provincia,
|
||||||
materialSelections: prefs.materialSelections,
|
materialSelections: prefs.materialSelections,
|
||||||
|
anteriorA2000: lead.anteriorA2000 ?? false,
|
||||||
|
cambioDistribucion: lead.cambioDistribucion ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { BudgetInputs, CatalogItem, PricingConfig } from '@/budget/types';
|
|||||||
const config: PricingConfig = {
|
const config: PricingConfig = {
|
||||||
alturaTechoDefault: 2.5,
|
alturaTechoDefault: 2.5,
|
||||||
factorZona: { Madrid: 1.1 },
|
factorZona: { Madrid: 1.1 },
|
||||||
manoObra: { demolicion: 1500, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 },
|
manoObra: { demolicion: 1500, impermeabilizacion: 0, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const catalog: CatalogItem[] = [
|
const catalog: CatalogItem[] = [
|
||||||
@@ -102,3 +102,50 @@ describe('computeBudget', () => {
|
|||||||
expect(r.total).toBeGreaterThan(0);
|
expect(r.total).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Config con impermeabilización y extras fijos activos, sin factor de zona (total = subtotal).
|
||||||
|
const configFull: PricingConfig = {
|
||||||
|
alturaTechoDefault: 2.5,
|
||||||
|
factorZona: {},
|
||||||
|
manoObra: { demolicion: 1500, impermeabilizacion: 2000, fontaneria: 1200, electricidad: 1000, mano_de_obra: 3000 },
|
||||||
|
extras: { tuberias: 100000, boletin: 15000, distribucion: 80000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('computeBudget — impermeabilización y extras fijos', () => {
|
||||||
|
it('añade impermeabilización en zonas húmedas (baño) proporcional al suelo', () => {
|
||||||
|
const r = computeBudget(inputs({ tipoReforma: 'bano', m2Suelo: 5, provincia: null }), configFull, catalog);
|
||||||
|
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
|
||||||
|
expect(byKey.impermeabilizacion).toBe(10000); // 5 * 2000
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no añade impermeabilización en zonas secas (salón)', () => {
|
||||||
|
const r = computeBudget(inputs({ tipoReforma: 'salon', provincia: null }), configFull, catalog);
|
||||||
|
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
|
||||||
|
expect(byKey.impermeabilizacion).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aplica siempre el boletín eléctrico como extra fijo', () => {
|
||||||
|
const r = computeBudget(inputs({ provincia: null }), configFull, catalog);
|
||||||
|
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
|
||||||
|
expect(byKey.extras_fijos).toBe(15000); // solo boletín
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suma tuberías y distribución según los inputs del piso', () => {
|
||||||
|
const r = computeBudget(
|
||||||
|
inputs({ provincia: null, anteriorA2000: true, cambioDistribucion: true }),
|
||||||
|
configFull,
|
||||||
|
catalog,
|
||||||
|
);
|
||||||
|
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
|
||||||
|
expect(byKey.extras_fijos).toBe(195000); // 15000 + 100000 + 80000
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la intensidad por tipo reduce la fontanería de un integral frente a una cocina', () => {
|
||||||
|
const cocina = computeBudget(inputs({ tipoReforma: 'cocina', m2Suelo: 16, provincia: null }), configFull, catalog);
|
||||||
|
const integral = computeBudget(inputs({ tipoReforma: 'integral', m2Suelo: 16, provincia: null }), configFull, catalog);
|
||||||
|
const fontCocina = cocina.partidas.find((p) => p.key === 'fontaneria')?.importe ?? 0;
|
||||||
|
const fontIntegral = integral.partidas.find((p) => p.key === 'fontaneria')?.importe ?? 0;
|
||||||
|
expect(fontCocina).toBe(19200); // 16 * 1200 * 1.0
|
||||||
|
expect(fontIntegral).toBe(8640); // 16 * 1200 * 0.45
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { BudgetInputs, PricingConfig } from '@/budget/types';
|
|||||||
const config: PricingConfig = {
|
const config: PricingConfig = {
|
||||||
alturaTechoDefault: 2.5,
|
alturaTechoDefault: 2.5,
|
||||||
factorZona: {},
|
factorZona: {},
|
||||||
manoObra: { demolicion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0 },
|
manoObra: { demolicion: 0, impermeabilizacion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
function inputs(partial: Partial<BudgetInputs>): BudgetInputs {
|
function inputs(partial: Partial<BudgetInputs>): BudgetInputs {
|
||||||
|
|||||||
Reference in New Issue
Block a user