# Motor de Presupuesto Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Construir el motor de presupuesto de Reformix: una función pura que produce un desglose por partidas a partir de precios configurables + medidas mínimas, con panel para editar la tabla de precios (CRUD + import CSV), seed de catálogo demo, y el punto de integración que el funnel llamará para recalcular y guardar el presupuesto del lead. **Architecture:** Núcleo puro en `src/budget/` (sin DB ni red, cubierto por Vitest ≥70%) que recibe `(inputs, config, catalog)` y devuelve `BudgetResult`. La capa Drizzle persiste `pricing_config` + `catalog_items` por tenant y nuevos campos de inputs/snapshot en `leads`. El panel `/panel/precios` hace CRUD sobre config/catálogo. Una Server Action `recalcularPresupuesto(leadId)` une las piezas: lee inputs del lead, llama al motor, guarda total + snapshot. **Tech Stack:** Next.js 16 (App Router, Server Actions), TypeScript strict, Drizzle ORM + postgres.js, Tailwind v4, Vitest + @vitest/coverage-v8, zod. Todo dentro de `mvp/b2c`. Tenant único `reformas-ejemplo`. **Convención de dinero:** todo en **céntimos** (enteros), igual que el schema actual. **Comandos:** ejecutar siempre desde `mvp/b2c`. Tests: `npm run test`. Cobertura: `npm run test:coverage`. --- ## Estructura de ficheros **Crear:** - `mvp/b2c/vitest.config.ts` — config de Vitest (alias `@`, cobertura sobre `src/budget/**`). - `mvp/b2c/src/budget/types.ts` — tipos del dominio (sin lógica). - `mvp/b2c/src/budget/labels.ts` — orden y etiquetas de partidas. - `mvp/b2c/src/budget/derive.ts` — `deriveCantidades()` (medidas → cantidades). - `mvp/b2c/src/budget/resolve.ts` — `resolvePrecioUnitario()` (lookup en catálogo). - `mvp/b2c/src/budget/compute.ts` — `computeBudget()` (orquesta todo). - `mvp/b2c/src/budget/csv.ts` — `parseCatalogCsv()` (parser + validación zod). - `mvp/b2c/src/budget/index.ts` — re-exports públicos. - `mvp/b2c/tests/budget/derive.test.ts` - `mvp/b2c/tests/budget/resolve.test.ts` - `mvp/b2c/tests/budget/compute.test.ts` - `mvp/b2c/tests/budget/csv.test.ts` - `mvp/b2c/src/db/pricing-queries.ts` — `getPricingConfig()`, `getCatalog()` (mapean fila DB → tipos del motor). - `mvp/b2c/src/app/panel/precios/page.tsx` — UI del panel de precios. - `mvp/b2c/src/app/panel/precios/actions.ts` — Server Actions CRUD + CSV. **Modificar:** - `mvp/b2c/package.json` — deps (`zod`) + devDeps (`vitest`, `@vitest/coverage-v8`) + scripts `test`, `test:coverage`. - `mvp/b2c/src/db/schema.ts` — enums + tablas `pricing_config`, `catalog_items` + campos nuevos en `leads`. - `mvp/b2c/src/db/seed.ts` — sembrar `pricing_config` + catálogo demo + inputs demo en un lead. - `mvp/b2c/src/app/panel/actions.ts` — añadir `recalcularPresupuesto(leadId)`. - `mvp/b2c/src/app/panel/[id]/page.tsx` — mostrar el desglose del presupuesto. - `mvp/b2c/src/app/panel/layout.tsx` — enlace de navegación a `/panel/precios`. --- ## Task 1: Montar Vitest **Files:** - Modify: `mvp/b2c/package.json` - Create: `mvp/b2c/vitest.config.ts` - Create: `mvp/b2c/tests/smoke.test.ts` - [ ] **Step 1: Instalar dependencias de test** Run (desde `mvp/b2c`): ```bash npm install -D vitest @vitest/coverage-v8 npm install zod ``` Expected: `package.json` actualizado, sin errores. - [ ] **Step 2: Añadir scripts a package.json** En `mvp/b2c/package.json`, dentro de `"scripts"`, añadir tras `"db:seed"`: ```json "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" ``` - [ ] **Step 3: Crear vitest.config.ts** ```ts import { defineConfig } from 'vitest/config'; import { fileURLToPath } from 'node:url'; export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, }, test: { environment: 'node', include: ['tests/**/*.test.ts'], coverage: { provider: 'v8', include: ['src/budget/**'], thresholds: { lines: 70, functions: 70, statements: 70, branches: 70 }, }, }, }); ``` - [ ] **Step 4: Crear un test de humo** `mvp/b2c/tests/smoke.test.ts`: ```ts import { describe, it, expect } from 'vitest'; describe('vitest setup', () => { it('runs', () => { expect(1 + 1).toBe(2); }); }); ``` - [ ] **Step 5: Ejecutar y verificar que pasa** Run: `npm run test` Expected: 1 passed. - [ ] **Step 6: Commit** ```bash git add mvp/b2c/package.json mvp/b2c/package-lock.json mvp/b2c/vitest.config.ts mvp/b2c/tests/smoke.test.ts git commit -m "chore: set up vitest and add zod" ``` --- ## Task 2: Tipos del dominio y etiquetas de partidas **Files:** - Create: `mvp/b2c/src/budget/types.ts` - Create: `mvp/b2c/src/budget/labels.ts` Sin tests propios (son solo tipos/constantes; se ejercitan en Tasks 3-5). - [ ] **Step 1: Crear types.ts** ```ts export type Calidad = 'basica' | 'media' | 'premium'; export type Unidad = 'm2' | 'ml' | 'ud'; export type CategoriaMaterial = 'suelo' | 'pared' | 'pintura' | 'mobiliario'; export type TipoReforma = 'cocina' | 'bano' | 'salon' | 'comedor' | 'integral' | 'otro'; export type PartidaKey = | 'demolicion' | 'alicatado' | 'fontaneria' | 'electricidad' | 'carpinteria' | 'mano_de_obra' | 'extras' | 'licencia'; export type ManoObraKey = 'demolicion' | 'fontaneria' | 'electricidad' | 'mano_de_obra'; export interface CatalogItem { id: string; categoria: CategoriaMaterial; nombre: string; calidad: Calidad; precioUnit: number; // céntimos por unidad unidad: Unidad; descriptorRender: string; esDefault: boolean; sku: string; } export interface PricingConfig { alturaTechoDefault: number; // metros factorZona: Record; // provincia -> multiplicador manoObra: Record; // céntimos por m² de suelo } export interface BudgetInputs { tipoReforma: TipoReforma; m2Suelo: number | null; alturaTecho: number | null; calidadGlobal: Calidad; estructural: boolean; provincia: string | null; materialSelections: Partial>; // categoria -> catalogItemId } export interface PartidaResult { key: PartidaKey; label: string; importe: number; // céntimos (base, antes de factor zona) } export interface BudgetResult { partidas: PartidaResult[]; subtotal: number; // céntimos factorZona: number; total: number; // céntimos = round(subtotal * factorZona) rango: { min: number; max: number }; // céntimos confianza: 'baja' | 'media' | 'alta'; materialesRender: string[]; // descriptores para el prompt del render avisos: string[]; } ``` - [ ] **Step 2: Crear labels.ts** ```ts import type { PartidaKey } from './types'; export const PARTIDA_ORDER: PartidaKey[] = [ 'demolicion', 'alicatado', 'fontaneria', 'electricidad', 'carpinteria', 'mano_de_obra', 'extras', 'licencia', ]; export const PARTIDA_LABEL: Record = { demolicion: 'Demolición', alicatado: 'Alicatado y solado', fontaneria: 'Fontanería', electricidad: 'Electricidad', carpinteria: 'Carpintería y mobiliario', mano_de_obra: 'Mano de obra', extras: 'Pintura y extras', licencia: 'Licencia + Proyecto técnico', }; ``` - [ ] **Step 3: Commit** ```bash git add mvp/b2c/src/budget/types.ts mvp/b2c/src/budget/labels.ts git commit -m "feat: add budget domain types and partida labels" ``` --- ## Task 3: Derivar cantidades desde las medidas **Files:** - Create: `mvp/b2c/src/budget/derive.ts` - Test: `mvp/b2c/tests/budget/derive.test.ts` - [ ] **Step 1: Escribir el test que falla** `mvp/b2c/tests/budget/derive.test.ts`: ```ts import { describe, it, expect } from 'vitest'; import { deriveCantidades } from '@/budget/derive'; import type { BudgetInputs, PricingConfig } from '@/budget/types'; const config: PricingConfig = { alturaTechoDefault: 2.5, factorZona: {}, manoObra: { demolicion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0 }, }; function inputs(partial: Partial): BudgetInputs { return { tipoReforma: 'cocina', m2Suelo: null, alturaTecho: null, calidadGlobal: 'media', estructural: false, provincia: null, materialSelections: {}, ...partial, }; } describe('deriveCantidades', () => { it('usa m² aportados y deriva perímetro y paredes con números limpios', () => { // m2Suelo=16 -> sqrt=4 -> perimetro=16 -> pared=16*2.5=40 -> mobiliario=16*0.5=8 (cocina) const c = deriveCantidades(inputs({ m2Suelo: 16 }), config); expect(c.m2Suelo).toBe(16); expect(c.perimetro).toBe(16); expect(c.m2Pared).toBe(40); expect(c.mlMobiliario).toBe(8); expect(c.alturaTecho).toBe(2.5); }); it('cae a la mediana por tipo cuando no hay m²', () => { const c = deriveCantidades(inputs({ tipoReforma: 'bano', m2Suelo: null }), config); expect(c.m2Suelo).toBe(5); // mediana baño }); it('usa la altura aportada por encima del default', () => { const c = deriveCantidades(inputs({ m2Suelo: 16, alturaTecho: 3 }), config); expect(c.alturaTecho).toBe(3); expect(c.m2Pared).toBe(48); // 16 * 3 }); it('no calcula mobiliario para tipos sin cocina/baño', () => { const c = deriveCantidades(inputs({ tipoReforma: 'salon', m2Suelo: 16 }), config); expect(c.mlMobiliario).toBe(0); }); }); ``` - [ ] **Step 2: Ejecutar para verificar que falla** Run: `npm run test -- derive` Expected: FAIL ("Cannot find module '@/budget/derive'" o similar). - [ ] **Step 3: Implementar derive.ts** ```ts import type { BudgetInputs, PricingConfig, TipoReforma } from './types'; export interface Cantidades { m2Suelo: number; m2Pared: number; mlMobiliario: number; perimetro: number; alturaTecho: number; } const M2_MEDIANA: Record = { cocina: 10, bano: 5, salon: 20, comedor: 16, integral: 70, otro: 12, }; // Metros lineales de mobiliario por metro de perímetro. Solo cocina/baño. const FACTOR_MOBILIARIO: Partial> = { cocina: 0.5, bano: 0.3, }; export function deriveCantidades(inputs: BudgetInputs, config: PricingConfig): Cantidades { const m2Suelo = inputs.m2Suelo != null && inputs.m2Suelo > 0 ? inputs.m2Suelo : M2_MEDIANA[inputs.tipoReforma]; const alturaTecho = inputs.alturaTecho != null && inputs.alturaTecho > 0 ? inputs.alturaTecho : config.alturaTechoDefault; const perimetro = 4 * Math.sqrt(m2Suelo); const m2Pared = perimetro * alturaTecho; const mlMobiliario = perimetro * (FACTOR_MOBILIARIO[inputs.tipoReforma] ?? 0); return { m2Suelo, m2Pared, mlMobiliario, perimetro, alturaTecho }; } ``` - [ ] **Step 4: Ejecutar para verificar que pasa** Run: `npm run test -- derive` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add mvp/b2c/src/budget/derive.ts mvp/b2c/tests/budget/derive.test.ts git commit -m "feat: derive cantidades from minimal measurements" ``` --- ## Task 4: Resolver el precio unitario desde el catálogo **Files:** - Create: `mvp/b2c/src/budget/resolve.ts` - Test: `mvp/b2c/tests/budget/resolve.test.ts` - [ ] **Step 1: Escribir el test que falla** `mvp/b2c/tests/budget/resolve.test.ts`: ```ts import { describe, it, expect } from 'vitest'; import { resolvePrecioUnitario } from '@/budget/resolve'; import type { CatalogItem } from '@/budget/types'; const catalog: CatalogItem[] = [ { id: 's-media', categoria: 'suelo', nombre: 'Cerámico medio', calidad: 'media', precioUnit: 2800, unidad: 'm2', descriptorRender: 'suelo cerámico gris', esDefault: true, sku: 'SUE-M' }, { id: 's-premium', categoria: 'suelo', nombre: 'Porcelánico roble', calidad: 'premium', precioUnit: 4500, unidad: 'm2', descriptorRender: 'porcelánico símil roble', esDefault: true, sku: 'SUE-P' }, ]; describe('resolvePrecioUnitario', () => { it('devuelve el default de la calidad cuando no hay selección', () => { const { item } = resolvePrecioUnitario('suelo', 'media', catalog, {}); expect(item?.id).toBe('s-media'); }); it('prioriza la selección exacta sobre la calidad global', () => { const { item } = resolvePrecioUnitario('suelo', 'media', catalog, { suelo: 's-premium' }); expect(item?.id).toBe('s-premium'); }); it('devuelve null si no hay default para esa calidad ni selección', () => { const { item } = resolvePrecioUnitario('pared', 'media', catalog, {}); expect(item).toBeNull(); }); }); ``` - [ ] **Step 2: Ejecutar para verificar que falla** Run: `npm run test -- resolve` Expected: FAIL ("Cannot find module '@/budget/resolve'"). - [ ] **Step 3: Implementar resolve.ts** ```ts import type { Calidad, CategoriaMaterial, CatalogItem } from './types'; export function resolvePrecioUnitario( categoria: CategoriaMaterial, calidad: Calidad, catalog: CatalogItem[], selections: Partial>, ): { item: CatalogItem | null } { const selectedId = selections[categoria]; if (selectedId) { const selected = catalog.find((c) => c.id === selectedId); if (selected) return { item: selected }; } const def = catalog.find( (c) => c.categoria === categoria && c.calidad === calidad && c.esDefault, ); return { item: def ?? null }; } ``` - [ ] **Step 4: Ejecutar para verificar que pasa** Run: `npm run test -- resolve` Expected: PASS (3 tests). - [ ] **Step 5: Commit** ```bash git add mvp/b2c/src/budget/resolve.ts mvp/b2c/tests/budget/resolve.test.ts git commit -m "feat: resolve unit price from catalog with selection override" ``` --- ## Task 5: computeBudget (núcleo) **Files:** - Create: `mvp/b2c/src/budget/compute.ts` - Create: `mvp/b2c/src/budget/index.ts` - Test: `mvp/b2c/tests/budget/compute.test.ts` - [ ] **Step 1: Escribir el test que falla** `mvp/b2c/tests/budget/compute.test.ts`: ```ts 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 { 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); // m2Suelo=16 -> perimetro=16, pared=40, mobiliario=8 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 base = computeBudget(inputs({}), config, catalog); 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(base.rango.max + 33000 + 120000); // +33000 = round((536000+30000)*1.1)-589600 efecto licencia en total; +120000 = banda licencia }); 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); }); }); ``` > Nota sobre el test de licencia: con estructural el subtotal pasa a 566000, total = round(566000*1.1) = 622600 (= base.total + 33000). La banda media (±15%) da max = round(622600*1.15) = 715990, y se le suma la banda de licencia (120000). El test lo expresa como `base.rango.max (678040) + 33000 + 120000 = 831040`. Verifica que el cálculo del implementador cuadra; si la implementación de abajo da otro número exacto por redondeo, ajusta el valor esperado al de la implementación de referencia (no cambies la fórmula). - [ ] **Step 2: Ejecutar para verificar que falla** Run: `npm run test -- compute` Expected: FAIL ("Cannot find module '@/budget/compute'"). - [ ] **Step 3: Implementar compute.ts** ```ts 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 = { 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 = { demolicion: 0, alicatado: 0, fontaneria: 0, electricidad: 0, carpinteria: 0, mano_de_obra: 0, extras: 0, licencia: 0, }; const cantidadPorCategoria: Record = { 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 }; } ``` - [ ] **Step 4: Crear index.ts (re-exports)** ```ts 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'; ``` > `./csv` se crea en la Task 6. Si ejecutas esta task de forma aislada, comenta esa línea hasta completar la Task 6. - [ ] **Step 5: Ejecutar para verificar que pasa** Run: `npm run test -- compute` Expected: PASS. Si el test de licencia falla por el valor esperado exacto, ajusta SOLO ese número al que produce esta implementación (ver nota del Step 1). - [ ] **Step 6: Commit** ```bash git add mvp/b2c/src/budget/compute.ts mvp/b2c/src/budget/index.ts mvp/b2c/tests/budget/compute.test.ts git commit -m "feat: implement computeBudget with partidas, zona factor, licencia and range" ``` --- ## Task 6: Parser de catálogo CSV **Files:** - Create: `mvp/b2c/src/budget/csv.ts` - Test: `mvp/b2c/tests/budget/csv.test.ts` Formato CSV (primera línea = cabecera): `categoria,nombre,calidad,precio,unidad,descriptor_render,sku` `precio` viene en **euros** (decimal con punto) y se convierte a céntimos. - [ ] **Step 1: Escribir el test que falla** `mvp/b2c/tests/budget/csv.test.ts`: ```ts import { describe, it, expect } from 'vitest'; import { parseCatalogCsv } from '@/budget/csv'; const HEADER = 'categoria,nombre,calidad,precio,unidad,descriptor_render,sku'; describe('parseCatalogCsv', () => { it('parsea filas válidas y convierte precio a céntimos', () => { const csv = [ HEADER, 'suelo,Cerámico gris,media,28.00,m2,suelo cerámico gris,SUE-M', 'mobiliario,Muebles cocina,premium,550,ml,muebles laminado roble,MOB-P', ].join('\n'); const { rows, errors } = parseCatalogCsv(csv); expect(errors).toHaveLength(0); expect(rows).toHaveLength(2); expect(rows[0]).toMatchObject({ categoria: 'suelo', calidad: 'media', precioUnit: 2800, unidad: 'm2', sku: 'SUE-M', }); expect(rows[1].precioUnit).toBe(55000); }); it('reporta errores por fila sin abortar las válidas', () => { const csv = [ HEADER, 'suelo,Bueno,media,28,m2,desc,SUE-M', 'inventada,Malo,media,10,m2,desc,X', // categoria inválida 'pared,Sin precio,media,abc,m2,desc,PAR-M', // precio no numérico ].join('\n'); const { rows, errors } = parseCatalogCsv(csv); expect(rows).toHaveLength(1); expect(errors).toHaveLength(2); expect(errors[0].line).toBe(3); // 1-indexed incluyendo cabecera expect(errors[1].line).toBe(4); }); it('devuelve error global si falta la cabecera esperada', () => { const { rows, errors } = parseCatalogCsv('a,b,c\n1,2,3'); expect(rows).toHaveLength(0); expect(errors[0].message).toMatch(/cabecera/i); }); }); ``` - [ ] **Step 2: Ejecutar para verificar que falla** Run: `npm run test -- csv` Expected: FAIL ("Cannot find module '@/budget/csv'"). - [ ] **Step 3: Implementar csv.ts** ```ts import { z } from 'zod'; import type { CategoriaMaterial, Calidad, Unidad } from './types'; export interface ParsedCatalogRow { categoria: CategoriaMaterial; nombre: string; calidad: Calidad; precioUnit: number; // céntimos unidad: Unidad; descriptorRender: string; sku: string; } export interface CsvError { line: number; // 1-indexed (la cabecera es la línea 1) message: string; } const HEADER = ['categoria', 'nombre', 'calidad', 'precio', 'unidad', 'descriptor_render', 'sku']; const rowSchema = z.object({ categoria: z.enum(['suelo', 'pared', 'pintura', 'mobiliario']), nombre: z.string().min(1), calidad: z.enum(['basica', 'media', 'premium']), precio: z .string() .transform((s) => Number(s)) .refine((n) => Number.isFinite(n) && n > 0, 'precio inválido'), unidad: z.enum(['m2', 'ml', 'ud']), descriptor_render: z.string(), sku: z.string().min(1), }); export function parseCatalogCsv(text: string): { rows: ParsedCatalogRow[]; errors: CsvError[] } { const lines = text .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l.length > 0); if (lines.length === 0) { return { rows: [], errors: [{ line: 1, message: 'CSV vacío' }] }; } const header = lines[0].split(',').map((h) => h.trim()); if (HEADER.some((h, i) => header[i] !== h)) { return { rows: [], errors: [{ line: 1, message: `Cabecera inválida. Esperada: ${HEADER.join(',')}` }], }; } const rows: ParsedCatalogRow[] = []; const errors: CsvError[] = []; for (let i = 1; i < lines.length; i++) { const cells = lines[i].split(',').map((c) => c.trim()); const record = Object.fromEntries(HEADER.map((h, idx) => [h, cells[idx] ?? ''])); const parsed = rowSchema.safeParse(record); if (!parsed.success) { errors.push({ line: i + 1, message: parsed.error.issues[0]?.message ?? 'fila inválida' }); continue; } const d = parsed.data; rows.push({ categoria: d.categoria, nombre: d.nombre, calidad: d.calidad, precioUnit: Math.round(d.precio * 100), unidad: d.unidad, descriptorRender: d.descriptor_render, sku: d.sku, }); } return { rows, errors }; } ``` - [ ] **Step 4: Ejecutar para verificar que pasa** Run: `npm run test -- csv` Expected: PASS (3 tests). - [ ] **Step 5: Verificar cobertura del motor** Run: `npm run test:coverage` Expected: PASS y cobertura de `src/budget/**` ≥ 70% en lines/functions/statements/branches. - [ ] **Step 6: Commit** ```bash git add mvp/b2c/src/budget/csv.ts mvp/b2c/tests/budget/csv.test.ts git commit -m "feat: add catalog CSV parser with per-row validation" ``` --- ## Task 7: Extender el schema Drizzle **Files:** - Modify: `mvp/b2c/src/db/schema.ts` Sin test unitario (es definición de schema; se valida con la migración en Task 8). - [ ] **Step 1: Añadir imports faltantes** En `mvp/b2c/src/db/schema.ts`, en el bloque de import de `drizzle-orm/pg-core`, añadir `doublePrecision` y `uniqueIndex` a la lista existente: ```ts import { pgTable, pgEnum, uuid, text, integer, boolean, numeric, timestamp, jsonb, index, doublePrecision, uniqueIndex, } from 'drizzle-orm/pg-core'; ``` - [ ] **Step 2: Añadir los enums nuevos** Tras el bloque del enum `tipoReforma` (antes de la tabla `tenants`), añadir: ```ts export const calidad = pgEnum('calidad', ['basica', 'media', 'premium']); export const categoriaMaterial = pgEnum('categoria_material', [ 'suelo', 'pared', 'pintura', 'mobiliario', ]); export const unidadMedida = pgEnum('unidad_medida', ['m2', 'ml', 'ud']); ``` - [ ] **Step 3: Añadir campos nuevos a la tabla `leads`** Dentro del objeto de columnas de `leads`, tras la línea `notas: text('notas'),`, añadir (antes del cierre `}` del objeto de columnas): ```ts // Inputs del motor de presupuesto (capturados de menos a más en el funnel) m2Suelo: doublePrecision('m2_suelo'), alturaTecho: doublePrecision('altura_techo'), calidadGlobal: calidad('calidad_global'), estructural: boolean('estructural').notNull().default(false), materialSelections: jsonb('material_selections') .$type>() .notNull() .default({}), desgloseSnapshot: jsonb('desglose_snapshot'), ``` - [ ] **Step 4: Añadir las tablas pricing_config y catalog_items** Tras la definición de `precisionHistory` (antes del bloque de `export type`), añadir: ```ts // Configuración de precios del reformista (1 fila por tenant). RF-D-07. export const pricingConfig = pgTable('pricing_config', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id') .notNull() .references(() => tenants.id, { onDelete: 'cascade' }) .unique(), alturaTechoDefault: doublePrecision('altura_techo_default').notNull().default(2.5), factorZona: jsonb('factor_zona').$type>().notNull().default({}), manoObra: jsonb('mano_obra').$type>().notNull().default({}), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); // Catálogo de materiales del reformista. Importable por CSV. export const catalogItems = pgTable( 'catalog_items', { id: uuid('id').primaryKey().defaultRandom(), tenantId: uuid('tenant_id') .notNull() .references(() => tenants.id, { onDelete: 'cascade' }), categoria: categoriaMaterial('categoria').notNull(), nombre: text('nombre').notNull(), calidad: calidad('calidad').notNull(), precioUnit: integer('precio_unit').notNull(), // céntimos por unidad unidad: unidadMedida('unidad').notNull(), descriptorRender: text('descriptor_render').notNull().default(''), esDefault: boolean('es_default').notNull().default(false), sku: text('sku').notNull(), }, (table) => [ index('catalog_tenant_idx').on(table.tenantId), uniqueIndex('catalog_tenant_sku_idx').on(table.tenantId, table.sku), ] ); ``` - [ ] **Step 5: Añadir los tipos inferidos** Al final del fichero, tras `export type PrecisionHistory = ...`, añadir: ```ts export type PricingConfigRow = typeof pricingConfig.$inferSelect; export type CatalogItemRow = typeof catalogItems.$inferSelect; export type NewCatalogItem = typeof catalogItems.$inferInsert; ``` - [ ] **Step 6: Verificar que compila el typecheck del schema** Run: `npx tsc --noEmit` Expected: sin errores en `src/db/schema.ts` (puede haber errores preexistentes en otros ficheros; ignóralos si no son del schema). - [ ] **Step 7: Commit** ```bash git add mvp/b2c/src/db/schema.ts git commit -m "feat: add pricing_config, catalog_items and budget input fields to schema" ``` --- ## Task 8: Generar migración y sembrar config + catálogo demo **Files:** - Create: `mvp/b2c/drizzle/0001_*.sql` (lo genera drizzle-kit) - Modify: `mvp/b2c/src/db/seed.ts` Requiere `DATABASE_URL` en `mvp/b2c/.env.local` apuntando a un Postgres accesible. - [ ] **Step 1: Generar la migración** Run (desde `mvp/b2c`): `npm run db:generate` Expected: nuevo fichero en `drizzle/0001_*.sql` con `CREATE TABLE pricing_config`, `CREATE TABLE catalog_items`, los nuevos `CREATE TYPE` y los `ALTER TABLE leads ADD COLUMN`. - [ ] **Step 2: Añadir el sembrado de precios y catálogo al seed** En `mvp/b2c/src/db/seed.ts`, justo antes de `await client.end();` (al final de `main()`), añadir este bloque autocontenido: ```ts // --- Precios + catálogo demo (motor de presupuesto) --- const [tenantRow] = await db .select() .from(schema.tenants) .where(eq(schema.tenants.slug, 'reformas-ejemplo')) .limit(1); if (tenantRow) { await db.delete(schema.catalogItems).where(eq(schema.catalogItems.tenantId, tenantRow.id)); await db.delete(schema.pricingConfig).where(eq(schema.pricingConfig.tenantId, tenantRow.id)); await db.insert(schema.pricingConfig).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 }, }); const cat = ( categoria: 'suelo' | 'pared' | 'pintura' | 'mobiliario', nombre: string, calidad: 'basica' | 'media' | 'premium', precioEuros: number, unidad: 'm2' | 'ml' | 'ud', descriptorRender: string, sku: string, ) => ({ tenantId: tenantRow.id, categoria, nombre, calidad, precioUnit: Math.round(precioEuros * 100), unidad, descriptorRender, esDefault: true, sku, }); 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('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'), cat('mobiliario', 'Muebles melamina', 'basica', 180, 'ml', 'muebles cocina melamina blanca', 'MOB-B'), cat('mobiliario', 'Muebles laminado', 'media', 320, 'ml', 'muebles cocina laminado roble con tirador integrado', 'MOB-M'), cat('mobiliario', 'Muebles lacado', 'premium', 550, 'ml', 'muebles cocina lacado mate antracita y encimera porcelánica', 'MOB-P'), ]); // Inputs demo en un lead ya avanzado para poder recalcular su presupuesto. await db .update(schema.leads) .set({ m2Suelo: 12, calidadGlobal: 'media', estructural: false }) .where(eq(schema.leads.email, 'roberto.salas@example.com')); } ``` > Nota: el bloque es idempotente por sí mismo (borra config+catálogo del tenant y reinserta). Usa `schema.catalogItems`, `schema.pricingConfig`, `schema.leads` ya importados vía `import * as schema`. `eq` ya está importado en seed.ts. - [ ] **Step 3: Aplicar migración y seed** Run (desde `mvp/b2c`): ```bash npm run db:migrate SEED_FORCE=1 npm run db:seed ``` Expected: migración aplicada; seed imprime su log sin errores. - [ ] **Step 4: Verificar datos** Run (desde `mvp/b2c`): ```bash node -e "const p=require('postgres')(process.env.DATABASE_URL,{prepare:false});p\`select count(*) from catalog_items\`.then(r=>{console.log(r);return p.end()})" ``` Expected: count = 12. (Si prefieres, usa `npm run db:studio` para inspeccionar visualmente.) - [ ] **Step 5: Commit** ```bash git add mvp/b2c/drizzle mvp/b2c/src/db/seed.ts git commit -m "feat: migrate and seed pricing config + demo catalog" ``` --- ## Task 9: Queries para mapear DB → tipos del motor **Files:** - Create: `mvp/b2c/src/db/pricing-queries.ts` Sin test unitario (capa de acceso a DB; se valida en el panel y en la integración). - [ ] **Step 1: Implementar pricing-queries.ts** ```ts import { eq } from 'drizzle-orm'; import { db } from './index'; import { pricingConfig, catalogItems, tenants } from './schema'; import { TENANT_SLUG } from '@/lib/funnel'; import type { PricingConfig, CatalogItem, ManoObraKey } from '@/budget/types'; async function getTenantId(): Promise { const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1); if (!tenant) throw new Error(`Tenant "${TENANT_SLUG}" no existe. ¿Has corrido npm run db:seed?`); return tenant.id; } const MANO_OBRA_DEFAULT: Record = { demolicion: 0, fontaneria: 0, electricidad: 0, mano_de_obra: 0, }; export async function getPricingConfig(): Promise { const tenantId = await getTenantId(); const [row] = await db .select() .from(pricingConfig) .where(eq(pricingConfig.tenantId, tenantId)) .limit(1); if (!row) { return { alturaTechoDefault: 2.5, factorZona: {}, manoObra: { ...MANO_OBRA_DEFAULT } }; } return { alturaTechoDefault: row.alturaTechoDefault, factorZona: row.factorZona, manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record) }, }; } export async function getCatalog(): Promise { const tenantId = await getTenantId(); const rows = await db.select().from(catalogItems).where(eq(catalogItems.tenantId, tenantId)); return rows.map((r) => ({ id: r.id, categoria: r.categoria, nombre: r.nombre, calidad: r.calidad, precioUnit: r.precioUnit, unidad: r.unidad, descriptorRender: r.descriptorRender, esDefault: r.esDefault, sku: r.sku, })); } export { getTenantId }; ``` - [ ] **Step 2: Verificar typecheck** Run: `npx tsc --noEmit` Expected: sin errores nuevos en `pricing-queries.ts`. - [ ] **Step 3: Commit** ```bash git add mvp/b2c/src/db/pricing-queries.ts git commit -m "feat: add queries mapping pricing config and catalog to engine types" ``` --- ## Task 10: Panel de precios (CRUD + import CSV) **Files:** - Create: `mvp/b2c/src/app/panel/precios/actions.ts` - Create: `mvp/b2c/src/app/panel/precios/page.tsx` - Modify: `mvp/b2c/src/app/panel/layout.tsx` - [ ] **Step 1: Crear las Server Actions** `mvp/b2c/src/app/panel/precios/actions.ts`: ```ts 'use server'; import { and, eq } from 'drizzle-orm'; import { revalidatePath } from 'next/cache'; import { db } from '@/db'; import { catalogItems, pricingConfig } from '@/db/schema'; import { getTenantId } from '@/db/pricing-queries'; import { parseCatalogCsv } from '@/budget/csv'; export async function crearMaterial(formData: FormData) { const tenantId = await getTenantId(); await db.insert(catalogItems).values({ tenantId, categoria: formData.get('categoria') as 'suelo' | 'pared' | 'pintura' | 'mobiliario', nombre: String(formData.get('nombre') ?? ''), calidad: formData.get('calidad') as 'basica' | 'media' | 'premium', precioUnit: Math.round(Number(formData.get('precioEuros') ?? 0) * 100), unidad: formData.get('unidad') as 'm2' | 'ml' | 'ud', descriptorRender: String(formData.get('descriptorRender') ?? ''), esDefault: formData.get('esDefault') === 'on', sku: String(formData.get('sku') ?? ''), }); revalidatePath('/panel/precios'); } export async function actualizarPrecio(formData: FormData) { const tenantId = await getTenantId(); const id = String(formData.get('id') ?? ''); await db .update(catalogItems) .set({ precioUnit: Math.round(Number(formData.get('precioEuros') ?? 0) * 100) }) .where(and(eq(catalogItems.id, id), eq(catalogItems.tenantId, tenantId))); revalidatePath('/panel/precios'); } export async function borrarMaterial(formData: FormData) { const tenantId = await getTenantId(); const id = String(formData.get('id') ?? ''); await db.delete(catalogItems).where(and(eq(catalogItems.id, id), eq(catalogItems.tenantId, tenantId))); revalidatePath('/panel/precios'); } export async function actualizarConfig(formData: FormData) { const tenantId = await getTenantId(); await db .update(pricingConfig) .set({ alturaTechoDefault: Number(formData.get('alturaTechoDefault') ?? 2.5), manoObra: { demolicion: Math.round(Number(formData.get('mo_demolicion') ?? 0) * 100), fontaneria: Math.round(Number(formData.get('mo_fontaneria') ?? 0) * 100), electricidad: Math.round(Number(formData.get('mo_electricidad') ?? 0) * 100), mano_de_obra: Math.round(Number(formData.get('mo_mano_de_obra') ?? 0) * 100), }, updatedAt: new Date(), }) .where(eq(pricingConfig.tenantId, tenantId)); revalidatePath('/panel/precios'); } export type ImportResult = { ok: boolean; inserted: number; errors: { line: number; message: string }[] }; export async function importarCatalogoCsv(_prev: ImportResult | null, formData: FormData): Promise { const tenantId = await getTenantId(); const csv = String(formData.get('csv') ?? ''); const { rows, errors } = parseCatalogCsv(csv); if (errors.length > 0) return { ok: false, inserted: 0, errors }; for (const r of rows) { await db .insert(catalogItems) .values({ tenantId, ...r }) .onConflictDoUpdate({ target: [catalogItems.tenantId, catalogItems.sku], set: { categoria: r.categoria, nombre: r.nombre, calidad: r.calidad, precioUnit: r.precioUnit, unidad: r.unidad, descriptorRender: r.descriptorRender, }, }); } revalidatePath('/panel/precios'); return { ok: true, inserted: rows.length, errors: [] }; } ``` - [ ] **Step 2: Crear la página del panel de precios** `mvp/b2c/src/app/panel/precios/page.tsx`: ```tsx import { getPricingConfig, getCatalog } from '@/db/pricing-queries'; import { formatEuros } from '@/lib/funnel'; import { crearMaterial, actualizarPrecio, borrarMaterial, actualizarConfig, } from './actions'; export const dynamic = 'force-dynamic'; const CATEGORIAS = ['suelo', 'pared', 'pintura', 'mobiliario'] as const; const CATEGORIA_LABEL: Record<(typeof CATEGORIAS)[number], string> = { suelo: 'Suelos', pared: 'Paredes / alicatado', pintura: 'Pinturas', mobiliario: 'Mobiliario', }; export default async function PreciosPage() { const [config, catalog] = await Promise.all([getPricingConfig(), getCatalog()]); return (

Tabla de precios

Define los precios unitarios y la mano de obra. El motor calcula el presupuesto a partir de estos valores y las medidas del lead.

{/* Config general */}

Configuración general

{(['demolicion', 'fontaneria', 'electricidad', 'mano_de_obra'] as const).map((k) => ( ))}
{/* Catálogo por categoría */} {CATEGORIAS.map((categoria) => { const items = catalog.filter((c) => c.categoria === categoria); return (

{CATEGORIA_LABEL[categoria]}

{items.length === 0 &&

Sin materiales.

} {items.map((item) => (
{item.nombre} {item.calidad} {item.unidad} {item.esDefault && ( default )}
))}
); })} {/* Import CSV */}

Importar catálogo (CSV)

Cabecera: categoria,nombre,calidad,precio,unidad,descriptor_render,sku. El precio en euros. Actualiza por SKU.

void}>