diff --git a/docs/api/README.md b/docs/api/README.md index c72a1e0..0c3b678 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -104,6 +104,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/company/create.md`](routes/company/create.md) | `POST /company` | | [`routes/company/history.md`](routes/company/history.md) | `GET /company/history` | | [`routes/company/addHistory.md`](routes/company/addHistory.md) | `POST /company/history` | +| [`routes/company/import.md`](routes/company/import.md) | `POST /company/import` | | [`routes/companysetting/list.md`](routes/companysetting/list.md) | `GET /company/setting` | | [`routes/companysetting/get.md`](routes/companysetting/get.md) | `GET /company/setting/{key}` | | [`routes/companysetting/set.md`](routes/companysetting/set.md) | `PUT /company/setting/{key}` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 76a5a1d..4ac3556 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 190 API endpoints across all modules. +Overview of all 191 API endpoints across all modules. ### Notation @@ -273,6 +273,9 @@ _none_ #### Add company history entry — `POST /company/history` - Params: `body: { text: str }` - Returns: `201 { history: { id: int, date: str, text: str, companyuserId?: int|null } }` +#### Import company catalog from zip archive (SSE) — `POST /company/import` +- Params: `form: { file?: str }` +- Returns: `200 SSE` #### List company settings — `GET /company/setting` _none_ - Returns: `200 { settings: { key: str, value: str|null }[] }` diff --git a/docs/api/routes.md b/docs/api/routes.md index 97b35aa..6fbe7f0 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -1,6 +1,6 @@ # Dash API Routes Overview -Overview of all 145 API endpoints across all modules. +Overview of all 146 API endpoints across all modules. ### Notation @@ -247,6 +247,9 @@ _none_ #### Add company history entry — `POST /company/history` - Params: `body: { text: str }` - Returns: `201 { history: { id: int, date: str, text: str, companyuserId?: int|null } }` +#### Import company catalog from zip archive (SSE) — `POST /company/import` +- Params: `form: { file?: str }` +- Returns: `200 SSE` #### List company settings — `GET /company/setting` _none_ - Returns: `200 { settings: { key: str, value: str|null }[] }` diff --git a/docs/api/routes/company/import.md b/docs/api/routes/company/import.md new file mode 100644 index 0000000..f5cb6e4 --- /dev/null +++ b/docs/api/routes/company/import.md @@ -0,0 +1,41 @@ +# `POST /company/import` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Import company catalog from zip archive (SSE) + +Uploads a .zip archive containing company export data (categories, articles, pictures), +preserves archive contents under `data/import//`, and executes full catalog +import (categories hierarchy, articles upsert, variant linking, image processing). +Streams progress events via Server-Sent Events (`text/event-stream`). + + +## Body (`multipart/form-data`) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `file` | no | file | ZIP archive (.zip) containing company data | + +## Response `200` + +Import progress stream (`text/event-stream`) or JSON result + +```json +{ + "ok": true, + "message": "Import erfolgreich abgeschlossen.", + "stats": {} +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Bad request (missing file, not a zip, or no company selected) — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 500 | Server error — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/src/api/routes/company.js b/src/api/routes/company.js index 85be584..2eb21ae 100644 --- a/src/api/routes/company.js +++ b/src/api/routes/company.js @@ -1,7 +1,41 @@ +import fs from 'fs'; +import path from 'path'; +import multer from 'multer'; +import JSZip from 'jszip'; import * as db from '../../db/company.js'; import * as googleAuth from '../../db/googleAuth.js'; import { clearCachedUser } from '../middleware/auth.js'; import { initCompanyVfs } from '../../tools/vfs.js'; +import { executeCompanyImport } from '../../services/companyImport.js'; + +const uploadZip = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB + fileFilter: (_req, file, cb) => { + const name = file.originalname?.toLowerCase() || ''; + const isZipExt = name.endsWith('.zip'); + const isZipMime = + file.mimetype === 'application/zip' || + file.mimetype === 'application/x-zip-compressed'; + if (isZipExt || isZipMime) { + cb(null, true); + } else { + cb(new Error('Only .zip archives are accepted')); + } + }, +}).single('file'); + +function uploadCompanyImportFile(req, res, next) { + uploadZip(req, res, (err) => { + if (err) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ error: 'File too large (max 100 MB)' }); + } + return res.status(400).json({ error: err.message }); + } + next(); + }); +} function userPayload(authRow) { return { @@ -348,6 +382,201 @@ export function register(app, basePath = '') { } }); + /** + * @openapi + * /company/import: + * post: + * tags: [Company] + * summary: Import company catalog from zip archive (SSE) + * description: | + * Uploads a .zip archive containing company export data (categories, articles, pictures), + * preserves archive contents under `data/import//`, and executes full catalog + * import (categories hierarchy, articles upsert, variant linking, image processing). + * Streams progress events via Server-Sent Events (`text/event-stream`). + * security: + * - cookieAuth: [] + * x-markdown: company/import.md + * requestBody: + * required: false + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * file: + * type: string + * format: binary + * description: ZIP archive (.zip) containing company data + * responses: + * 200: + * description: Import progress stream (`text/event-stream`) or JSON result + * content: + * text/event-stream: + * schema: + * type: string + * application/json: + * schema: + * type: object + * required: [ok, message] + * properties: + * ok: + * type: boolean + * example: true + * message: + * type: string + * example: Import erfolgreich abgeschlossen. + * stats: + * type: object + * 400: + * description: Bad request (missing file, not a zip, or no company selected) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.post( + `${basePath}/company/import`, + uploadCompanyImportFile, + async (req, res) => { + let companyId; + try { + companyId = requireSelectedCompany(req); + } catch (error) { + return res.status(error.statusCode || 400).json({ error: error.message }); + } + + const importDir = path.resolve(process.cwd(), 'data/import', String(companyId)); + + // If a file was uploaded, unpack and preserve it + if (req.file?.buffer) { + const originalName = req.file.originalname || 'import.zip'; + if (!originalName.toLowerCase().endsWith('.zip')) { + return res.status(400).json({ error: 'Uploaded file must have a .zip extension' }); + } + + let zip; + try { + zip = await JSZip.loadAsync(req.file.buffer); + } catch (err) { + return res.status(400).json({ error: 'Invalid or corrupt zip archive' }); + } + + try { + fs.rmSync(importDir, { recursive: true, force: true }); + fs.mkdirSync(importDir, { recursive: true }); + + // Preserve the raw zip archive + fs.writeFileSync(path.join(importDir, 'import.zip'), req.file.buffer); + + console.log(`[Company Import] Extracting archive "${originalName}" into ${importDir}:`); + let fileCount = 0; + let dirCount = 0; + + for (const [relativePath, entry] of Object.entries(zip.files)) { + const safePath = path.normalize(relativePath).replace(/^(\.\.(\/|\\|$))+/, ''); + const destPath = path.resolve(importDir, safePath); + if (!destPath.startsWith(importDir)) { + console.warn(`[Company Import] Skipped unsafe path: ${relativePath}`); + continue; + } + + if (entry.dir) { + dirCount++; + fs.mkdirSync(destPath, { recursive: true }); + } else { + fileCount++; + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + const content = await entry.async('nodebuffer'); + fs.writeFileSync(destPath, content); + } + } + + console.log(`[Company Import] Preserved ${fileCount} files and ${dirCount} directories in ${importDir}`); + } catch (err) { + console.error('[Company Import] Failed to extract archive:', err); + return res.status(500).json({ error: 'Failed to extract import archive' }); + } + } else { + // No file uploaded in this request; check if existing preserved files are present + const catFile = path.join(importDir, 'categories.csv'); + const artFile = path.join(importDir, 'articles.csv'); + if (!fs.existsSync(catFile) || !fs.existsSync(artFile)) { + return res.status(400).json({ error: 'Zip file is required in "file" form field' }); + } + } + + const wantsSse = + req.headers.accept?.includes('text/event-stream') || + req.query.stream === '1' || + req.query.sse === '1' || + !req.headers.accept?.includes('application/json'); + + if (wantsSse) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + if (typeof res.flushHeaders === 'function') { + res.flushHeaders(); + } + + const sseWrite = (event, data) => { + if (res.writableEnded || res.destroyed) return; + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + if (typeof res.flush === 'function') res.flush(); + }; + + sseWrite('status', { message: 'Import gestartet. Daten werden verarbeitet...' }); + + try { + const outcome = await executeCompanyImport({ + googleAuthId: req.user.id, + companyId, + importDir, + onProgress: (phase, current, total, item) => { + sseWrite('progress', { phase, current, total, item }); + }, + }); + + sseWrite('done', outcome); + res.end(); + } catch (err) { + console.error('[Company Import] Import execution failed:', err); + sseWrite('error', { message: err.message || 'Import fehlgeschlagen' }); + res.end(); + } + } else { + try { + const outcome = await executeCompanyImport({ + googleAuthId: req.user.id, + companyId, + importDir, + onProgress: (phase, current, total, item) => { + console.log(`[Import Progress] [${phase}] ${current}/${total} - ${item}`); + }, + }); + + res.status(200).json(outcome); + } catch (err) { + console.error('[Company Import] Import execution failed:', err); + res.status(500).json({ error: err.message || 'Import fehlgeschlagen' }); + } + } + } + ); + /** * @openapi * /company/{id}: diff --git a/src/services/companyImport.js b/src/services/companyImport.js new file mode 100644 index 0000000..abf8127 --- /dev/null +++ b/src/services/companyImport.js @@ -0,0 +1,472 @@ +import fs from 'fs'; +import path from 'path'; +import { getStore } from '../db/store.js'; + +/** + * Parses RFC 4180 CSV text into an array of objects keyed by header. + * + * @param {string} text - Raw CSV text + * @returns {Array>} + */ +export function parseCsvRecords(text) { + const clean = text.replace(/^\uFEFF/, ''); + const rows = []; + let field = ''; + let row = []; + let inQuotes = false; + let i = 0; + const len = clean.length; + + while (i < len) { + const c = clean[i]; + if (inQuotes) { + if (c === '"') { + if (i + 1 < len && clean[i + 1] === '"') { + field += '"'; + i += 2; + continue; + } else { + inQuotes = false; + i++; + continue; + } + } else { + field += c; + i++; + continue; + } + } else { + if (c === '"') { + inQuotes = true; + i++; + continue; + } else if (c === ',') { + row.push(field); + field = ''; + i++; + continue; + } else if (c === '\r') { + if (i + 1 < len && clean[i + 1] === '\n') { + i++; + } + row.push(field); + rows.push(row); + field = ''; + row = []; + i++; + continue; + } else if (c === '\n') { + row.push(field); + rows.push(row); + field = ''; + row = []; + i++; + continue; + } else { + field += c; + i++; + continue; + } + } + } + if (field !== '' || row.length > 0) { + row.push(field); + rows.push(row); + } + if (rows.length === 0) return []; + const header = rows[0].map((h) => h.trim()); + const records = []; + for (let r = 1; r < rows.length; r++) { + const curr = rows[r]; + if (curr.length === 1 && curr[0] === '') continue; + const obj = {}; + for (let col = 0; col < header.length; col++) { + obj[header[col]] = curr[col] ?? ''; + } + records.push(obj); + } + return records; +} + +/** + * Executes full catalog import from preserved files in importDir. + * + * @param {object} opts + * @param {number} opts.googleAuthId + * @param {number} opts.companyId + * @param {string} opts.importDir + * @param {function} [opts.onProgress] - (phase, current, total, item) => void + * @returns {Promise} Summary outcome + */ +export async function executeCompanyImport({ + googleAuthId, + companyId, + importDir, + onProgress = () => {}, +}) { + const store = await getStore(); + + const catCsvPath = path.join(importDir, 'categories.csv'); + const artCsvPath = path.join(importDir, 'articles.csv'); + + if (!fs.existsSync(catCsvPath) || !fs.existsSync(artCsvPath)) { + throw new Error('Import files missing: categories.csv and articles.csv are required'); + } + + const catRaw = fs.readFileSync(catCsvPath, 'utf8'); + const catRecords = parseCsvRecords(catRaw); + + const artRaw = fs.readFileSync(artCsvPath, 'utf8'); + const artRecords = parseCsvRecords(artRaw); + + console.log(`[Import] Read ${catRecords.length} category rows and ${artRecords.length} article rows`); + + // ─── Phase 1: Category Mapping & Creation ──────────────────────────────── + // 1) Find existing category tree & company root category + const treeResult = await store.category.getTreeForCompany(googleAuthId); + const existingTree = treeResult.tree || []; + const rootCategory = existingTree.find((c) => c.parentId == null) || existingTree[0]; + if (!rootCategory) { + throw new Error('Company root category not found'); + } + const rootCategoryId = rootCategory.id; + + // Helper map of existing categories by parentId and lowercase name + const existingMap = new Map(); + for (const c of existingTree) { + const key = `${c.parentId ?? 'root'}:${String(c.name || '').trim().toLowerCase()}`; + existingMap.set(key, c.id); + } + + // Identify excluded categories (Papierkorb / Trash and its descendants) + const excludedJtlIds = new Set(['72']); + for (const c of catRecords) { + let name = ''; + try { + const tr = JSON.parse(c.translations || '[]'); + name = tr[0]?.cName || ''; + } catch {} + if (name.toLowerCase().includes('papierkorb') || name.toLowerCase().includes('trash')) { + excludedJtlIds.add(String(c.kKategorie)); + } + } + // Also exclude any children of excluded categories + let addedExcluded = true; + while (addedExcluded) { + addedExcluded = false; + for (const c of catRecords) { + const id = String(c.kKategorie); + const parent = String(c.kOberKategorie); + if (!excludedJtlIds.has(id) && excludedJtlIds.has(parent)) { + excludedJtlIds.add(id); + addedExcluded = true; + } + } + } + + console.log(`[Import] Excluded categories (trash/papierkorb):`, Array.from(excludedJtlIds)); + + // Filter included categories + const includedCategories = catRecords.filter((c) => !excludedJtlIds.has(String(c.kKategorie))); + + // Sort categories so parents are created before children + // JTL kOberKategorie === '0' are top-level + const jtlCatIdToDashCatId = new Map(); + const pendingCats = [...includedCategories]; + let createdCatCount = 0; + let catPicCount = 0; + + onProgress('categories', 0, includedCategories.length, 'Starting categories import'); + + let safety = 0; + while (pendingCats.length > 0 && safety < 10) { + safety++; + const remaining = []; + for (let i = 0; i < pendingCats.length; i++) { + const c = pendingCats[i]; + const jtlId = String(c.kKategorie); + const jtlParent = String(c.kOberKategorie); + + let name = ''; + try { + const tr = JSON.parse(c.translations || '[]'); + name = tr[0]?.cName || ''; + } catch {} + if (!name) name = `Kategorie ${jtlId}`; + + let dashParentId; + if (jtlParent === '0' || !jtlParent) { + dashParentId = rootCategoryId; + } else if (jtlCatIdToDashCatId.has(jtlParent)) { + dashParentId = jtlCatIdToDashCatId.get(jtlParent); + } else { + // Parent not created yet in this pass + remaining.push(c); + continue; + } + + // Check if category already exists under dashParentId + const key = `${dashParentId}:${name.trim().toLowerCase()}`; + let dashCatId = existingMap.get(key); + + if (!dashCatId) { + try { + const created = await store.category.createUnderParent({ + googleAuthId, + parentId: dashParentId, + name: name.trim(), + }); + dashCatId = created.id; + existingMap.set(key, dashCatId); + createdCatCount++; + } catch (err) { + console.warn(`[Import] Failed to create category "${name}":`, err.message); + } + } + + if (dashCatId) { + jtlCatIdToDashCatId.set(jtlId, dashCatId); + + // Check for category picture + const picRel = (c.pictureFiles || '').trim(); + if (picRel) { + const picPath = path.join(importDir, picRel); + if (fs.existsSync(picPath)) { + try { + const imgBuf = fs.readFileSync(picPath); + await store.category.addImage(googleAuthId, dashCatId, imgBuf); + catPicCount++; + } catch (err) { + console.warn(`[Import] Category image error (${picRel}):`, err.message); + } + } + } + } + + onProgress('categories', jtlCatIdToDashCatId.size, includedCategories.length, name); + } + pendingCats.length = 0; + pendingCats.push(...remaining); + } + + console.log(`[Import] Categories resolved: ${jtlCatIdToDashCatId.size}/${includedCategories.length}, created new: ${createdCatCount}, images: ${catPicCount}`); + + // Fallback: Haupt category ID for unmapped items + const hauptDashId = jtlCatIdToDashCatId.get('1') || rootCategoryId; + + // ─── Phase 2: Article Import Pass 1 (Create or Update) ─────────────────── + const includedArticles = artRecords.filter((a) => { + let cats = []; + try { + const rawCats = JSON.parse(a.categories || '[]'); + cats = rawCats.map((it) => String(it.kKategorie)); + } catch {} + // If all assigned categories are excluded, exclude article + if (cats.length > 0 && cats.every((cid) => excludedJtlIds.has(cid))) { + return false; + } + return true; + }); + + const totalArticles = includedArticles.length; + console.log(`[Import] Articles to process: ${totalArticles}`); + onProgress('articles', 0, totalArticles, 'Starting articles import'); + + let articlesImported = 0; + let articlesUpdated = 0; + const jtlArtIdToSku = new Map(); + const articlesWithPictures = []; + + for (let i = 0; i < totalArticles; i++) { + const a = includedArticles[i]; + const jtlArtId = String(a.kArtikel); + const sku = String(a.cArtNr || '').trim(); + if (!sku) continue; + + jtlArtIdToSku.set(jtlArtId, sku); + + let name = ''; + try { + const desc = JSON.parse(a.descriptions || '[]'); + name = desc[0]?.cName || ''; + } catch {} + if (!name) name = `Artikel ${sku}`; + + // Category mapping + const assignedDashCatIds = []; + try { + const rawCats = JSON.parse(a.categories || '[]'); + for (const it of rawCats) { + const jtlCId = String(it.kKategorie); + if (jtlCatIdToDashCatId.has(jtlCId)) { + assignedDashCatIds.push(jtlCatIdToDashCatId.get(jtlCId)); + } + } + } catch {} + const primaryCatId = assignedDashCatIds[0] || hauptDashId; + + // Stock tracking + const trackStock = a.cLagerAktiv === 'Y' ? 1 : 0; + + // Price & Tax + const netPrice = parseFloat(String(a.fVKNetto || '0').replace(',', '.')) || 0; + // kSteuerklasse '3' is reduced tax (7%), '1' is standard tax (19%) + const taxRate = a.kSteuerklasse === '3' ? 7 : 19; + + // Barcode / EAN + const barcode = String(a.cBarcode || '').trim(); + + // Check if article already exists + let existingArticle = null; + try { + const res = await store.article.getBySku(googleAuthId, sku); + existingArticle = res.article; + } catch { + existingArticle = null; + } + + try { + if (existingArticle) { + // Update existing article + await store.article.updateBySku(googleAuthId, sku, { + name: name.trim(), + track_stock: trackStock, + }); + + // Update prices + await store.article.setPricesBySku(googleAuthId, sku, [ + { net: netPrice, tax: taxRate }, + ]); + + // Update category assignment + if (assignedDashCatIds.length > 0) { + await store.article.setCategoryIdsBySku(googleAuthId, sku, assignedDashCatIds); + } + + // Update barcode if provided + if (barcode) { + const currentEans = existingArticle.eans || []; + if (!currentEans.includes(barcode)) { + await store.article.setEansBySku(googleAuthId, sku, [...currentEans, barcode]); + } + } + + articlesUpdated++; + } else { + // Create new article + await store.article.createUnderParent({ + googleAuthId, + parentId: primaryCatId, + name: name.trim(), + sku, + track_stock: trackStock, + }); + + // Set price + await store.article.setPricesBySku(googleAuthId, sku, [ + { net: netPrice, tax: taxRate }, + ]); + + // Set additional categories if more than 1 + if (assignedDashCatIds.length > 1) { + await store.article.setCategoryIdsBySku(googleAuthId, sku, assignedDashCatIds); + } + + // Set barcode if present + if (barcode) { + await store.article.setEansBySku(googleAuthId, sku, [barcode]); + } + + articlesImported++; + } + } catch (err) { + console.warn(`[Import] Failed to save article ${sku} ("${name}"):`, err.message); + } + + // Keep note of picture references for image phase + const rawPics = (a.pictureFiles || '').trim(); + if (rawPics) { + articlesWithPictures.push({ sku, pictureFiles: rawPics }); + } + + if (i % 25 === 0 || i === totalArticles - 1) { + onProgress('articles', i + 1, totalArticles, `${sku} - ${name}`); + } + } + + console.log(`[Import] Article pass 1 complete. New: ${articlesImported}, Updated: ${articlesUpdated}`); + + // ─── Phase 3: Article Import Pass 2 (Variant Links) ────────────────────── + const variantChildren = includedArticles.filter( + (a) => a.kVaterArtikel && a.kVaterArtikel !== '0' && a.kVaterArtikel.trim() !== '' + ); + console.log(`[Import] Linking ${variantChildren.length} child variants to parents...`); + onProgress('variants', 0, variantChildren.length, 'Linking variants'); + + let variantsLinked = 0; + for (let i = 0; i < variantChildren.length; i++) { + const child = variantChildren[i]; + const childSku = String(child.cArtNr || '').trim(); + const parentJtlId = String(child.kVaterArtikel || '').trim(); + const parentSku = jtlArtIdToSku.get(parentJtlId); + + if (childSku && parentSku && childSku !== parentSku) { + try { + await store.article.setParentBySku(googleAuthId, childSku, parentSku); + variantsLinked++; + } catch (err) { + console.warn(`[Import] Failed to link child ${childSku} to parent ${parentSku}:`, err.message); + } + } + if (i % 20 === 0 || i === variantChildren.length - 1) { + onProgress('variants', i + 1, variantChildren.length, `${childSku} -> ${parentSku}`); + } + } + console.log(`[Import] Linked ${variantsLinked}/${variantChildren.length} variants`); + + // ─── Phase 4: Article Pictures Import ──────────────────────────────────── + console.log(`[Import] Importing pictures for ${articlesWithPictures.length} articles...`); + onProgress('images', 0, articlesWithPictures.length, 'Importing article pictures'); + + let totalImagesImported = 0; + for (let i = 0; i < articlesWithPictures.length; i++) { + const { sku, pictureFiles } = articlesWithPictures[i]; + // Picture files may be separated by newline or semicolon + const lines = pictureFiles.replace(/\r/g, '\n').split('\n').map((l) => l.trim()).filter(Boolean); + + for (const relPath of lines) { + const imgPath = path.join(importDir, relPath); + if (!fs.existsSync(imgPath)) continue; + + try { + const imgBuf = fs.readFileSync(imgPath); + await store.article.addImage(googleAuthId, sku, imgBuf); + totalImagesImported++; + } catch (err) { + // If already attached or format issue, log and continue + console.warn(`[Import] Image add failed for SKU ${sku} (${relPath}):`, err.message); + } + } + + if (i % 10 === 0 || i === articlesWithPictures.length - 1) { + onProgress('images', i + 1, articlesWithPictures.length, `SKU ${sku} (${lines.length} img)`); + } + } + + console.log(`[Import] Completed article pictures: ${totalImagesImported} images processed`); + + return { + ok: true, + message: 'Import erfolgreich abgeschlossen.', + stats: { + categories: jtlCatIdToDashCatId.size, + categoriesCreated: createdCatCount, + categoryPictures: catPicCount, + articlesTotal: totalArticles, + articlesImported, + articlesUpdated, + variantsLinked, + articlePictures: totalImagesImported, + }, + }; +}