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:
seb
2026-09-15 08:50:16 +02:00
parent 4df5110253
commit dbf978d015
9 changed files with 2064 additions and 33 deletions

236
src/utils/importArtikel.js Normal file
View 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 };
}