- Add PDF-Upload button to ManifestHeader (delivery forms only) - New ManifestPdfDialog: live SSE event log, structured basedata, and line-items table streamed from POST /api/delivery/parsePDF - ApiClient.delivery.parsePdfSse: multipart/form-data upload, async generator yielding typed SSE events (status, llm_call, tool_call, usage, progress, result, error) - ManifestForm.handlePdfUpload: drives SSE generator, updates dialog state in real-time; dialog opens immediately on upload - Fix MUI icon imports (CheckCircle, ErrorOutlined)
185 lines
4.8 KiB
JavaScript
185 lines
4.8 KiB
JavaScript
/**
|
|
* Client-side bulk import of the shop `artikeltabelle.csv` export format.
|
|
*
|
|
* The CSV is semicolon-separated (often wrapped in `="…"` from Excel export)
|
|
* with columns:
|
|
* ArtNr; Name; Preis; USt; Pfand; Hersteller; Kategorie; Barcode; V/K
|
|
*
|
|
* All work is done through the existing dashback API:
|
|
* - categories → POST /api/category
|
|
* - brands → PUT /api/brand
|
|
* - articles → POST /api/article (+ price / brand / ean / categories PUTs)
|
|
*/
|
|
|
|
/** Parse one `="value"` / `"value"` / plain Excel-style CSV cell. */
|
|
export function stripCell(value) {
|
|
if (value == null) return '';
|
|
let text = String(value).trim();
|
|
if (text.startsWith('=')) text = text.slice(1);
|
|
text = text.trim();
|
|
if (
|
|
text.length >= 2 &&
|
|
text.startsWith('"') &&
|
|
text.endsWith('"')
|
|
) {
|
|
text = text.slice(1, -1).replace(/""/g, '"');
|
|
}
|
|
return text.trim();
|
|
}
|
|
|
|
/**
|
|
* Split a semicolon CSV line into cells, honoring double-quoted fields.
|
|
* (Cell values are still raw; use `stripCell` for the Excel `="…"` wrapper.)
|
|
*/
|
|
export function splitCsvLine(line) {
|
|
const cells = [];
|
|
let cur = '';
|
|
let inQuotes = false;
|
|
for (let i = 0; i < line.length; i++) {
|
|
const ch = line[i];
|
|
if (inQuotes) {
|
|
if (ch === '"') {
|
|
if (line[i + 1] === '"') {
|
|
cur += '"';
|
|
i++;
|
|
} else {
|
|
inQuotes = false;
|
|
}
|
|
} else {
|
|
cur += ch;
|
|
}
|
|
} else if (ch === '"') {
|
|
inQuotes = true;
|
|
} else if (ch === ';') {
|
|
cells.push(cur);
|
|
cur = '';
|
|
} else {
|
|
cur += ch;
|
|
}
|
|
}
|
|
cells.push(cur);
|
|
return cells.map((c) => stripCell(c));
|
|
}
|
|
|
|
/**
|
|
* Parse a CSV string into row objects keyed by the header names.
|
|
* Skips blank lines and the optional `sep=;` hint line.
|
|
*/
|
|
export function parseArtikelCsv(csvText) {
|
|
const lines = String(csvText ?? '')
|
|
.replace(/\r\n/g, '\n')
|
|
.split('\n')
|
|
.map((l) => l.trim())
|
|
.filter((l) => l !== '' && l !== 'sep=;');
|
|
|
|
if (lines.length === 0) return [];
|
|
|
|
const headers = splitCsvLine(lines[0]);
|
|
const rows = [];
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const cells = splitCsvLine(lines[i]);
|
|
const row = {};
|
|
headers.forEach((header, idx) => {
|
|
row[header] = cells[idx] ?? '';
|
|
});
|
|
if (row.Name) rows.push(row);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Resolve a de-DE (or plain) number from a CSV field.
|
|
* Accepts `2,00`, `1.234,56`, and plain `2.00`.
|
|
*/
|
|
export function parseCsvNumber(value) {
|
|
const text = String(value ?? '').trim();
|
|
if (text === '') return null;
|
|
let normalized = text.replace(/\s/g, '');
|
|
if (normalized.includes(',')) {
|
|
normalized = normalized.replace(/\./g, '').replace(',', '.');
|
|
}
|
|
const parsed = Number(normalized);
|
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
}
|
|
|
|
/** EAN from a CSV cell (digits only), or null. */
|
|
export function parseCsvEan(value) {
|
|
const digits = String(value ?? '').replace(/\D/g, '');
|
|
return digits || null;
|
|
}
|
|
|
|
/**
|
|
* Split a `Kategorie` path (`Haupt | Grow | Erde`) into trimmed segments.
|
|
* Empty / falsy path → [].
|
|
*/
|
|
export function parseCategoryPath(value) {
|
|
return String(value ?? '')
|
|
.split('|')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Column index → name. Uses a forgiving match so the exact header text
|
|
* (with or without `="` wrapper, case) doesn't matter.
|
|
*/
|
|
function headerName(name) {
|
|
return stripCell(name).toLowerCase().trim();
|
|
}
|
|
|
|
export const COLUMNS = {
|
|
artNr: 'artnr',
|
|
name: 'name',
|
|
price: 'preis',
|
|
tax: 'ust',
|
|
deposit: 'pfand',
|
|
brand: 'hersteller',
|
|
category: 'kategorie',
|
|
ean: 'barcode',
|
|
vk: 'v/k',
|
|
};
|
|
|
|
/**
|
|
* Normalize one raw CSV row object into the typed import record.
|
|
* Unknown/missing columns become null/[] so downstream code can skip them.
|
|
*/
|
|
export function normalizeRow(row, headers = []) {
|
|
const byName = new Map();
|
|
(headers.length ? headers : Object.keys(row)).forEach((h) => {
|
|
byName.set(headerName(h), row[h] ?? row[stripCell(h)] ?? '');
|
|
});
|
|
const get = (key) => byName.get(COLUMNS[key]) ?? byName.get(key) ?? '';
|
|
|
|
const artNr = get('artNr');
|
|
const name = get('name');
|
|
const price = parseCsvNumber(get('price'));
|
|
const tax = parseCsvNumber(get('tax'));
|
|
const brand = get('brand');
|
|
const categoryPath = parseCategoryPath(get('category'));
|
|
const ean = parseCsvEan(get('ean'));
|
|
const vk = get('vk');
|
|
|
|
return {
|
|
artNr,
|
|
name,
|
|
price,
|
|
tax,
|
|
deposit: parseCsvNumber(get('deposit')),
|
|
brand,
|
|
categoryPath,
|
|
ean,
|
|
vk,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse a full CSV string into typed import records.
|
|
* @param {string} csvText
|
|
* @returns {Array<ReturnType<typeof normalizeRow>>}
|
|
*/
|
|
export function parseImportCsv(csvText) {
|
|
const rawRows = parseArtikelCsv(csvText);
|
|
const headers = rawRows.length ? Object.keys(rawRows[0]) : [];
|
|
return rawRows.map((row) => normalizeRow(row, headers));
|
|
}
|