u
This commit is contained in:
@@ -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}` |
|
||||
|
||||
@@ -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 }[] }`
|
||||
|
||||
@@ -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 }[] }`
|
||||
|
||||
41
docs/api/routes/company/import.md
Normal file
41
docs/api/routes/company/import.md
Normal 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`._
|
||||
@@ -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/<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
|
||||
* /company/{id}:
|
||||
|
||||
472
src/services/companyImport.js
Normal file
472
src/services/companyImport.js
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user