feat: add catalog CSV parser with per-row validation

This commit is contained in:
Carlos Narro
2026-05-30 12:24:47 +02:00
parent 896c7ac89b
commit 58d3f62a76
3 changed files with 122 additions and 1 deletions

76
mvp/b2c/src/budget/csv.ts Normal file
View File

@@ -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 };
}

View File

@@ -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';

View File

@@ -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);
});
});