feat: add catalog CSV parser with per-row validation
This commit is contained in:
76
mvp/b2c/src/budget/csv.ts
Normal file
76
mvp/b2c/src/budget/csv.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -3,4 +3,4 @@ export { PARTIDA_LABEL, PARTIDA_ORDER } from './labels';
|
|||||||
export { deriveCantidades } from './derive';
|
export { deriveCantidades } from './derive';
|
||||||
export { resolvePrecioUnitario } from './resolve';
|
export { resolvePrecioUnitario } from './resolve';
|
||||||
export { computeBudget } from './compute';
|
export { computeBudget } from './compute';
|
||||||
// export { parseCatalogCsv } from './csv'; // added in Task 6
|
export { parseCatalogCsv } from './csv';
|
||||||
|
|||||||
45
mvp/b2c/tests/budget/csv.test.ts
Normal file
45
mvp/b2c/tests/budget/csv.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user