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/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}` |
|
||||||
|
|||||||
@@ -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 }[] }`
|
||||||
|
|||||||
@@ -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 }[] }`
|
||||||
|
|||||||
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 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}:
|
||||||
|
|||||||
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