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 { resolvePrecioUnitario } from './resolve';
|
||||
export { computeBudget } from './compute';
|
||||
// export { parseCatalogCsv } from './csv'; // added in Task 6
|
||||
export { parseCatalogCsv } from './csv';
|
||||
|
||||
Reference in New Issue
Block a user