diff --git a/mvp/b2c/src/budget/csv.ts b/mvp/b2c/src/budget/csv.ts new file mode 100644 index 0000000..8d34346 --- /dev/null +++ b/mvp/b2c/src/budget/csv.ts @@ -0,0 +1,76 @@ +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 }; +} diff --git a/mvp/b2c/src/budget/index.ts b/mvp/b2c/src/budget/index.ts index bce7b3b..e23a5fb 100644 --- a/mvp/b2c/src/budget/index.ts +++ b/mvp/b2c/src/budget/index.ts @@ -3,4 +3,4 @@ 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 +export { parseCatalogCsv } from './csv'; diff --git a/mvp/b2c/tests/budget/csv.test.ts b/mvp/b2c/tests/budget/csv.test.ts new file mode 100644 index 0000000..551089e --- /dev/null +++ b/mvp/b2c/tests/budget/csv.test.ts @@ -0,0 +1,45 @@ +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); + }); +});