This commit is contained in:
seb
2026-10-08 02:53:32 +02:00
parent cd396363bd
commit 73461c6e6f
6 changed files with 751 additions and 2 deletions

View File

@@ -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/create.md`](routes/company/create.md) | `POST /company` |
| [`routes/company/history.md`](routes/company/history.md) | `GET /company/history` | | [`routes/company/history.md`](routes/company/history.md) | `GET /company/history` |
| [`routes/company/addHistory.md`](routes/company/addHistory.md) | `POST /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/list.md`](routes/companysetting/list.md) | `GET /company/setting` |
| [`routes/companysetting/get.md`](routes/companysetting/get.md) | `GET /company/setting/{key}` | | [`routes/companysetting/get.md`](routes/companysetting/get.md) | `GET /company/setting/{key}` |
| [`routes/companysetting/set.md`](routes/companysetting/set.md) | `PUT /company/setting/{key}` | | [`routes/companysetting/set.md`](routes/companysetting/set.md) | `PUT /company/setting/{key}` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 190 API endpoints across all modules. Overview of all 191 API endpoints across all modules.
### Notation ### Notation
@@ -273,6 +273,9 @@ _none_
#### Add company history entry — `POST /company/history` #### Add company history entry — `POST /company/history`
- Params: `body: { text: str }` - Params: `body: { text: str }`
- Returns: `201 { history: { id: int, date: str, text: str, companyuserId?: int|null } }` - 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` #### List company settings — `GET /company/setting`
_none_ _none_
- Returns: `200 { settings: { key: str, value: str|null }[] }` - Returns: `200 { settings: { key: str, value: str|null }[] }`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview # Dash API Routes Overview
Overview of all 145 API endpoints across all modules. Overview of all 146 API endpoints across all modules.
### Notation ### Notation
@@ -247,6 +247,9 @@ _none_
#### Add company history entry — `POST /company/history` #### Add company history entry — `POST /company/history`
- Params: `body: { text: str }` - Params: `body: { text: str }`
- Returns: `201 { history: { id: int, date: str, text: str, companyuserId?: int|null } }` - 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` #### List company settings — `GET /company/setting`
_none_ _none_
- Returns: `200 { settings: { key: str, value: str|null }[] }` - Returns: `200 { settings: { key: str, value: str|null }[] }`

View File

@@ -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/<companyId>/`, 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`._

View File

@@ -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 db from '../../db/company.js';
import * as googleAuth from '../../db/googleAuth.js'; import * as googleAuth from '../../db/googleAuth.js';
import { clearCachedUser } from '../middleware/auth.js'; import { clearCachedUser } from '../middleware/auth.js';
import { initCompanyVfs } from '../../tools/vfs.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) { function userPayload(authRow) {
return { 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/<companyId>/`, 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 * @openapi
* /company/{id}: * /company/{id}:

View File

@@ -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<Record<string, string>>}
*/
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<object>} 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,
},
};
}