feat(lieferung/neu): PDF upload with SSE streaming and result dialog
- 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)
This commit is contained in:
236
src/utils/importArtikel.js
Normal file
236
src/utils/importArtikel.js
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Client-side bulk import engine for the shop `artikeltabelle.csv` format.
|
||||
*
|
||||
* Columns: ArtNr; Name; Preis; USt; Pfand; Hersteller; Kategorie; Barcode; V/K
|
||||
*
|
||||
* Everything is created through the existing dashback API:
|
||||
* - categories: POST /api/category ({ parentId, name })
|
||||
* - brands: PUT /api/brand ({ name }) → { brand: { id } }
|
||||
* - articles: POST /api/article ({ parentId, name }) → { article: { sku } }
|
||||
* - price: PUT /api/article/:sku/prices ({ prices: [{ price, tax }] })
|
||||
* - brand: PUT /api/article/:sku/brand ({ brandId })
|
||||
* - eans: PUT /api/article/:sku/eans ({ eans })
|
||||
* - categories: PUT /api/article/:sku/categories({ categoryIds })
|
||||
*
|
||||
* SKU is auto-allocated by the server (POST /article) — the CSV `ArtNr` is used
|
||||
* only as a de-duplication / reference key, not as the SKU.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseImportCsv,
|
||||
parseCategoryPath,
|
||||
} from './importArtikelCsv';
|
||||
import { article, category, brand } from '../api';
|
||||
import {
|
||||
requireObject,
|
||||
requireDefined,
|
||||
} from './apiShape';
|
||||
|
||||
function logImport(message) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[import] ${message}`);
|
||||
}
|
||||
|
||||
/** Build a category path lookup from the normalized category map (id → node). */
|
||||
function buildCategoryPathIndex(categories, rootCategoryId) {
|
||||
const byParent = new Map();
|
||||
for (const node of Object.values(categories ?? {})) {
|
||||
const key = node.parentId == null ? 'null' : String(node.parentId);
|
||||
if (!byParent.has(key)) byParent.set(key, []);
|
||||
byParent.get(key).push(node);
|
||||
}
|
||||
|
||||
const pathToId = new Map();
|
||||
const rootKey = String(rootCategoryId);
|
||||
const rootName = categories?.[rootKey]?.name ?? '';
|
||||
|
||||
const walk = (node, trail) => {
|
||||
const name = (node.name ?? '').trim();
|
||||
const nextTrail = [...trail, name];
|
||||
const pathKey = nextTrail.join('|').toLowerCase();
|
||||
if (!pathToId.has(pathKey)) {
|
||||
pathToId.set(pathKey, node.id);
|
||||
}
|
||||
for (const child of byParent.get(String(node.id)) ?? []) {
|
||||
walk(child, nextTrail);
|
||||
}
|
||||
};
|
||||
|
||||
if (rootName) walk({ id: rootCategoryId, name: rootName, parentId: null }, []);
|
||||
|
||||
// Also index each node directly by its own name so a single-segment path
|
||||
// that equals an existing category name resolves.
|
||||
for (const node of Object.values(categories ?? {})) {
|
||||
const name = (node.name ?? '').trim();
|
||||
if (name) {
|
||||
const key = name.toLowerCase();
|
||||
if (!pathToId.has(key)) pathToId.set(key, node.id);
|
||||
}
|
||||
}
|
||||
|
||||
return { pathToId, rootCategoryId, rootName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a category path `A | B | C` exists under root. Returns the leaf id.
|
||||
* Reuses an in-memory cache so repeated paths are cheap.
|
||||
*/
|
||||
function createEnsureCategory(cache) {
|
||||
return async function ensureCategory(segments) {
|
||||
if (!Array.isArray(segments) || segments.length === 0) {
|
||||
return cache.rootCategoryId;
|
||||
}
|
||||
|
||||
const clean = segments.map((s) => s.trim()).filter(Boolean);
|
||||
if (clean.length === 0) return cache.rootCategoryId;
|
||||
|
||||
const cacheKey = clean.join('|').toLowerCase();
|
||||
if (cache.byPath.has(cacheKey)) return cache.byPath.get(cacheKey);
|
||||
|
||||
// Walk from root to leaf, creating missing levels.
|
||||
let parentId = cache.rootCategoryId;
|
||||
let trail = [];
|
||||
for (const segment of clean) {
|
||||
trail.push(segment);
|
||||
const trailKey = trail.join('|').toLowerCase();
|
||||
const existing = cache.pathToId.get(trailKey);
|
||||
if (existing != null) {
|
||||
parentId = existing;
|
||||
continue;
|
||||
}
|
||||
// Create the missing category under the current parent.
|
||||
const res = await category.create({ parentId, name: segment });
|
||||
requireObject(res, 'POST /api/category');
|
||||
const created = res.category;
|
||||
requireDefined(created.id, 'category.id');
|
||||
cache.pathToId.set(trailKey, created.id);
|
||||
// Note: parent of next level is this new category.
|
||||
cache.byName.set(segment.toLowerCase(), created.id);
|
||||
parentId = created.id;
|
||||
}
|
||||
|
||||
cache.byPath.set(cacheKey, parentId);
|
||||
return parentId;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a brand by name → brandId.
|
||||
* PUT /api/brand upserts by name (same name ⇒ update, otherwise create).
|
||||
*/
|
||||
function createEnsureBrand(cache) {
|
||||
return async function ensureBrand(name) {
|
||||
const clean = String(name ?? '').trim();
|
||||
if (!clean) return null;
|
||||
const key = clean.toLowerCase();
|
||||
if (cache.brandByKey.has(key)) return cache.brandByKey.get(key);
|
||||
|
||||
const res = await brand.upsert({ name: clean });
|
||||
requireObject(res, 'PUT /api/brand');
|
||||
requireObject(res.brand, 'brand');
|
||||
requireDefined(res.brand.id, 'brand.id');
|
||||
cache.brandByKey.set(key, res.brand.id);
|
||||
return res.brand.id;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import one normalized CSV row through the API.
|
||||
* Returns a summary object for the caller to aggregate.
|
||||
*/
|
||||
async function importRow(row, { ensureCategory, ensureBrand }) {
|
||||
const name = String(row.name ?? '').trim();
|
||||
if (!name) return { status: 'skipped', reason: 'name-empty' };
|
||||
|
||||
const leafCategoryId = await ensureCategory(row.categoryPath);
|
||||
const brandId = row.brand ? await ensureBrand(row.brand) : null;
|
||||
|
||||
// Create the article under the leaf category.
|
||||
const createRes = await article.create({ parentId: leafCategoryId, name });
|
||||
requireObject(createRes, 'POST /api/article');
|
||||
const created = createRes.article;
|
||||
requireDefined(created, 'article');
|
||||
requireDefined(created.sku, 'article.sku');
|
||||
const sku = created.sku;
|
||||
|
||||
const applied = { sku, artNr: row.artNr || null, name };
|
||||
|
||||
// Price (net) + tax. CSV `Preis` is treated as net in the shop export.
|
||||
if (row.price != null) {
|
||||
const tax = row.tax == null ? 19 : row.tax;
|
||||
await article.setPrices(sku, { prices: [{ price: row.price, tax }] });
|
||||
applied.price = { net: row.price, tax };
|
||||
}
|
||||
|
||||
if (brandId != null) {
|
||||
await article.setBrandIdBySku(sku, brandId);
|
||||
applied.brandId = brandId;
|
||||
}
|
||||
|
||||
if (row.ean) {
|
||||
await article.setEansBySku(sku, { eans: [row.ean] });
|
||||
applied.ean = row.ean;
|
||||
}
|
||||
|
||||
// Article is created under the leaf category; keep the category membership.
|
||||
await article.setCategories(sku, { categoryIds: [leafCategoryId] });
|
||||
applied.categoryId = leafCategoryId;
|
||||
|
||||
return { status: 'created', ...applied };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a full CSV text into the selected company.
|
||||
*
|
||||
* @param {string} csvText Raw CSV text.
|
||||
* @param {object} options
|
||||
* @param {object} options.categories Normalized category map (id → node) from the store.
|
||||
* @param {number|string} options.rootCategoryId Company root category id.
|
||||
* @param {(msg: string) => void} [options.onProgress] Progress callback.
|
||||
* @returns {Promise<{ok:number, skipped:number, rows:Array}>}
|
||||
*/
|
||||
export async function importArtikelCsv(csvText, { categories, rootCategoryId, onProgress } = {}) {
|
||||
const rows = parseImportCsv(csvText);
|
||||
if (rows.length === 0) {
|
||||
throw new Error('Keine Artikel in der CSV gefunden.');
|
||||
}
|
||||
if (rootCategoryId == null) {
|
||||
throw new Error('Keine Root-Kategorie ausgewählt.');
|
||||
}
|
||||
|
||||
const cache = {
|
||||
rootCategoryId,
|
||||
pathToId: new Map(),
|
||||
byPath: new Map(),
|
||||
byName: new Map(),
|
||||
brandByKey: new Map(),
|
||||
};
|
||||
const { pathToId, rootName } = buildCategoryPathIndex(categories, rootCategoryId);
|
||||
cache.pathToId = pathToId;
|
||||
cache.rootName = rootName;
|
||||
|
||||
const ensureCategory = createEnsureCategory(cache);
|
||||
const ensureBrand = createEnsureBrand(cache);
|
||||
|
||||
const results = [];
|
||||
let ok = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
onProgress?.(`${i + 1}/${rows.length}`);
|
||||
const row = rows[i];
|
||||
try {
|
||||
const result = await importRow(row, { ensureCategory, ensureBrand });
|
||||
results.push(result);
|
||||
if (result.status === 'created') ok += 1;
|
||||
else skipped += 1;
|
||||
logImport(`ok ${row.name ?? ''} → ${result.sku ?? ''}`);
|
||||
} catch (err) {
|
||||
skipped += 1;
|
||||
logImport(`skip ${row.name ?? ''}: ${err.message ?? err}`);
|
||||
results.push({ status: 'error', name: row.name, error: err.message ?? String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return { ok, skipped, rows: results };
|
||||
}
|
||||
184
src/utils/importArtikelCsv.js
Normal file
184
src/utils/importArtikelCsv.js
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
Reference in New Issue
Block a user