/** * 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 ({ categoryId, 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 } from './importArtikelCsv'; import { article, category, brand } from '../api'; import { requireObject, requireDefined, } from './apiShape'; function logImport(message) { 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({ categoryId: 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 }; }