u
This commit is contained in:
@@ -113,6 +113,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
||||
| [`routes/category/_id/property/_propertyId.md`](routes/category/_id/property/_propertyId.md) | `DELETE /category/{id}/property/{propertyId}` |
|
||||
| [`routes/company/list.md`](routes/company/list.md) | `GET /company` |
|
||||
| [`routes/company/create.md`](routes/company/create.md) | `POST /company` |
|
||||
| [`routes/company/export.md`](routes/company/export.md) | `GET /company/export` |
|
||||
| [`routes/company/export.post.md`](routes/company/export.post.md) | `POST /company/export` |
|
||||
| [`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` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dash API Routes Overview (All)
|
||||
|
||||
Overview of all 233 API endpoints across all modules.
|
||||
Overview of all 235 API endpoints across all modules.
|
||||
|
||||
### Notation
|
||||
|
||||
@@ -340,6 +340,12 @@ _none_
|
||||
#### Create company — `POST /company`
|
||||
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||
- Returns: `201 { company: T23, user: T45 }`
|
||||
#### Export company catalog as zip archive (SSE) — `GET /company/export`
|
||||
- Params: `query: stream?: str`
|
||||
- Returns: `200 SSE`
|
||||
#### Export company catalog as zip archive (SSE) — `POST /company/export`
|
||||
_none_
|
||||
- Returns: `200 SSE`
|
||||
#### Get company history — `GET /company/history`
|
||||
- Params: `query: limit?: int`
|
||||
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
|
||||
|
||||
33
docs/api/routes/company/export.md
Normal file
33
docs/api/routes/company/export.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# `GET /company/export`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Export company catalog as zip archive (SSE)
|
||||
|
||||
Exports full company catalog data (categories hierarchy, articles, variants, brands, pictures)
|
||||
into a round-trip compatible .zip archive matching the format of `/company/import`.
|
||||
Streams progress events via Server-Sent Events (`text/event-stream`), followed by base64-encoded
|
||||
binary chunks in `chunk` events and a final `done` event.
|
||||
|
||||
|
||||
## Query parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `stream` | no | string | Force SSE stream mode (`1`) or direct binary download (`0`) |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Export progress stream (`text/event-stream`) with binary chunks, or raw ZIP file
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Bad request (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`._
|
||||
23
docs/api/routes/company/export.post.md
Normal file
23
docs/api/routes/company/export.post.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# `POST /company/export`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Export company catalog as zip archive (SSE)
|
||||
|
||||
POST variant of `/company/export` with identical SSE streaming semantics.
|
||||
|
||||
## Response `200`
|
||||
|
||||
Export progress stream (`text/event-stream`) with binary chunks, or raw ZIP file
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Bad request (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`._
|
||||
@@ -25,6 +25,8 @@ const OMITTED_ROUTES = new Set([
|
||||
'GET /category/export.zip',
|
||||
'DELETE /company/{id}',
|
||||
'POST /company',
|
||||
'GET /company/export',
|
||||
'POST /company/export',
|
||||
'POST /category/{id}/image',
|
||||
'POST /delivery/parse',
|
||||
'GET /events',
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
import { executeCompanyExport } from '../../services/companyExport.js';
|
||||
|
||||
// #region Helpers
|
||||
|
||||
@@ -605,6 +606,189 @@ export function register(app, basePath = '') {
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /company/export:
|
||||
* get:
|
||||
* tags: [Company]
|
||||
* summary: Export company catalog as zip archive (SSE)
|
||||
* description: |
|
||||
* Exports full company catalog data (categories hierarchy, articles, variants, brands, pictures)
|
||||
* into a round-trip compatible .zip archive matching the format of `/company/import`.
|
||||
* Streams progress events via Server-Sent Events (`text/event-stream`), followed by base64-encoded
|
||||
* binary chunks in `chunk` events and a final `done` event.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-markdown: company/export.md
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: stream
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: ['0', '1']
|
||||
* description: Force SSE stream mode (`1`) or direct binary download (`0`)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Export progress stream (`text/event-stream`) with binary chunks, or raw ZIP file
|
||||
* content:
|
||||
* text/event-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* application/zip:
|
||||
* schema:
|
||||
* type: string
|
||||
* format: binary
|
||||
* 400:
|
||||
* description: Bad request (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'
|
||||
* post:
|
||||
* tags: [Company]
|
||||
* summary: Export company catalog as zip archive (SSE)
|
||||
* description: POST variant of `/company/export` with identical SSE streaming semantics.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Export progress stream (`text/event-stream`) with binary chunks, or raw ZIP file
|
||||
* content:
|
||||
* text/event-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* application/zip:
|
||||
* schema:
|
||||
* type: string
|
||||
* format: binary
|
||||
* 400:
|
||||
* description: Bad request (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'
|
||||
*/
|
||||
// #region GET/POST /company/export
|
||||
|
||||
const handleCompanyExport = async (req, res) => {
|
||||
let companyId;
|
||||
try {
|
||||
companyId = requireSelectedCompany(req);
|
||||
} catch (error) {
|
||||
return res.status(error.statusCode || 400).json({ error: error.message });
|
||||
}
|
||||
|
||||
const wantsSse =
|
||||
req.headers.accept?.includes('text/event-stream') ||
|
||||
req.query.stream === '1' ||
|
||||
req.query.sse === '1' ||
|
||||
(!req.headers.accept?.includes('application/zip') && !req.path.endsWith('.zip') && req.query.download !== '1');
|
||||
|
||||
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;
|
||||
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
res.write(`event: ${event}\ndata: ${payload}\n\n`);
|
||||
if (typeof res.flush === 'function') res.flush();
|
||||
};
|
||||
|
||||
sseWrite('status', { message: 'Export gestartet. Daten werden verarbeitet...' });
|
||||
|
||||
try {
|
||||
const outcome = await executeCompanyExport({
|
||||
googleAuthId: req.user.id,
|
||||
companyId,
|
||||
onStatus: (status) => sseWrite('status', status),
|
||||
onProgress: (counts) => sseWrite('progress', counts),
|
||||
});
|
||||
|
||||
// Stream binary chunks as base64 in SSE 'chunk' events
|
||||
const CHUNK_SIZE = 64 * 1024; // 64 KB binary slice per SSE event
|
||||
const { zipBuffer, filename, stats } = outcome;
|
||||
const totalChunks = Math.ceil(zipBuffer.length / CHUNK_SIZE);
|
||||
|
||||
sseWrite('status', {
|
||||
message: `Sende Archiv (${(zipBuffer.length / (1024 * 1024)).toFixed(2)} MB in ${totalChunks} Chunks)...`,
|
||||
});
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
if (res.writableEnded || res.destroyed) break;
|
||||
const slice = zipBuffer.subarray(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
||||
const base64 = slice.toString('base64');
|
||||
res.write(`event: chunk\ndata: ${base64}\n\n`);
|
||||
if (typeof res.flush === 'function') res.flush();
|
||||
}
|
||||
|
||||
sseWrite('done', {
|
||||
ok: true,
|
||||
filename,
|
||||
size: zipBuffer.length,
|
||||
totalChunks,
|
||||
stats,
|
||||
});
|
||||
res.end();
|
||||
} catch (err) {
|
||||
console.error('[Company Export] Export execution failed:', err);
|
||||
sseWrite('error', { message: err.message || 'Export fehlgeschlagen' });
|
||||
res.end();
|
||||
}
|
||||
} else {
|
||||
// Direct binary download
|
||||
try {
|
||||
const outcome = await executeCompanyExport({
|
||||
googleAuthId: req.user.id,
|
||||
companyId,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${outcome.filename}"`);
|
||||
res.status(200).send(outcome.zipBuffer);
|
||||
} catch (err) {
|
||||
console.error('[Company Export] Export failed:', err);
|
||||
const status = companyErrorStatus(err);
|
||||
res.status(status).json({ error: err.message || 'Export fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.get(`${basePath}/company/export`, handleCompanyExport);
|
||||
app.get(`${basePath}/company/export.zip`, handleCompanyExport);
|
||||
app.post(`${basePath}/company/export`, handleCompanyExport);
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /company/{id}:
|
||||
|
||||
463
src/services/companyExport.js
Normal file
463
src/services/companyExport.js
Normal file
@@ -0,0 +1,463 @@
|
||||
// MARK: - Imports
|
||||
// #region Imports
|
||||
import JSZip from 'jszip';
|
||||
import { getStore } from '../db/store.js';
|
||||
import { createLogger } from '../util/logger.js';
|
||||
// #endregion
|
||||
|
||||
const log = createLogger('export');
|
||||
|
||||
// MARK: - CSV Helpers
|
||||
// #region CSV Helpers
|
||||
|
||||
/**
|
||||
* Formats a value as a double-quoted CSV field.
|
||||
*
|
||||
* @param {any} val
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatCsvField(val) {
|
||||
if (val == null) return '""';
|
||||
const str = String(val);
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number in German decimal format (e.g. 8.36 -> "8,36").
|
||||
*
|
||||
* @param {number|string|null} val
|
||||
* @param {string} [fallback='0']
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatGermanFloat(val, fallback = '0') {
|
||||
if (val == null || val === '') return fallback;
|
||||
const num = Number(val);
|
||||
if (Number.isNaN(num)) return fallback;
|
||||
return String(num).replace('.', ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts Pfand deposit amount from article options if present.
|
||||
*
|
||||
* @param {Array<object>} options
|
||||
* @returns {string} e.g. "pfand:0.25" or ""
|
||||
*/
|
||||
function extractPfandAttribute(options) {
|
||||
if (!Array.isArray(options) || options.length === 0) return '';
|
||||
for (const opt of options) {
|
||||
const sku = String(opt.sku || '').toUpperCase();
|
||||
const match = sku.match(/PFAND[-_]?([0-9]+(?:[.,][0-9]+)?)/i);
|
||||
if (match) {
|
||||
const val = parseFloat(match[1].replace(',', '.'));
|
||||
if (Number.isFinite(val) && val > 0) {
|
||||
return `pfand:${val.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
if (opt.name) {
|
||||
const nameMatch = String(opt.name).match(/pfand\s*([0-9]+(?:[.,][0-9]+)?)/i);
|
||||
if (nameMatch) {
|
||||
const val = parseFloat(nameMatch[1].replace(',', '.'));
|
||||
if (Number.isFinite(val) && val > 0) {
|
||||
return `pfand:${val.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// MARK: - Concurrency Pool
|
||||
// #region Concurrency Pool
|
||||
|
||||
/**
|
||||
* Runs tasks with a controlled concurrency limit.
|
||||
*
|
||||
* @template T, R
|
||||
* @param {Array<T>} items
|
||||
* @param {number} concurrency
|
||||
* @param {(item: T, index: number) => Promise<R>} fn
|
||||
* @returns {Promise<Array<R>>}
|
||||
*/
|
||||
async function asyncPool(items, concurrency, fn) {
|
||||
const results = new Array(items.length);
|
||||
let index = 0;
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||
while (index < items.length) {
|
||||
const current = index++;
|
||||
results[current] = await fn(items[current], current);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// MARK: - Company Export Orchestrator
|
||||
// #region Company Export Orchestrator
|
||||
|
||||
/**
|
||||
* Executes full catalog export from store into a round-trip compatible ZIP buffer.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {number} opts.googleAuthId
|
||||
* @param {number} opts.companyId
|
||||
* @param {function} [opts.onStatus] - (statusObj) => void
|
||||
* @param {function} [opts.onProgress] - (counts) => void
|
||||
* @returns {Promise<{ zipBuffer: Buffer, stats: object, filename: string }>}
|
||||
*/
|
||||
export async function executeCompanyExport({
|
||||
googleAuthId,
|
||||
companyId,
|
||||
onStatus = () => {},
|
||||
onProgress = () => {},
|
||||
}) {
|
||||
const store = await getStore();
|
||||
const zip = new JSZip();
|
||||
|
||||
// 1. Categories
|
||||
onStatus({ message: 'Lade Kategorien...' });
|
||||
const treeResult = await store.category.getTreeForCompany(googleAuthId);
|
||||
const categoryTree = treeResult.tree || [];
|
||||
|
||||
onProgress({
|
||||
stage: 'categories',
|
||||
categoriestotal: categoryTree.length,
|
||||
categoriesdone: categoryTree.length,
|
||||
percent: 10,
|
||||
});
|
||||
|
||||
// 2. Brands
|
||||
onStatus({ message: 'Lade Hersteller / Marken...' });
|
||||
const brands = [];
|
||||
let brandOffset = 0;
|
||||
const brandLimit = 100;
|
||||
while (true) {
|
||||
const brandListRes = await store.brand.list(googleAuthId, { offset: brandOffset, limit: brandLimit });
|
||||
const batch = brandListRes.brands || [];
|
||||
brands.push(...batch);
|
||||
brandOffset += batch.length;
|
||||
if (batch.length < brandLimit || brandOffset >= (brandListRes.total ?? 0)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const brandById = new Map();
|
||||
const brandByName = new Map();
|
||||
for (const b of brands) {
|
||||
brandById.set(b.id, b);
|
||||
if (b.name) brandByName.set(b.name.trim().toLowerCase(), b);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
stage: 'brands',
|
||||
brandstotal: brands.length,
|
||||
brandsdone: brands.length,
|
||||
percent: 15,
|
||||
});
|
||||
|
||||
// 3. Articles
|
||||
onStatus({ message: 'Lade Artikelliste...' });
|
||||
const exportArticles = await store.article.listExport(googleAuthId, null);
|
||||
const totalArticles = exportArticles.length;
|
||||
|
||||
onProgress({
|
||||
stage: 'articles',
|
||||
articlestotal: totalArticles,
|
||||
articlesdone: 0,
|
||||
percent: 20,
|
||||
});
|
||||
|
||||
onStatus({ message: `Lade Detaildaten für ${totalArticles} Artikel...` });
|
||||
let articlesProcessed = 0;
|
||||
const fullArticles = await asyncPool(exportArticles, 5, async (item) => {
|
||||
try {
|
||||
const detail = await store.article.getBySku(googleAuthId, item.sku);
|
||||
articlesProcessed++;
|
||||
if (articlesProcessed % 50 === 0 || articlesProcessed === totalArticles) {
|
||||
onProgress({
|
||||
stage: 'articles',
|
||||
articlestotal: totalArticles,
|
||||
articlesdone: articlesProcessed,
|
||||
percent: 20 + Math.floor((articlesProcessed / totalArticles) * 40),
|
||||
});
|
||||
}
|
||||
return detail.article;
|
||||
} catch (err) {
|
||||
log.warn(`Could not fetch details for article ${item.sku}:`, err.message);
|
||||
articlesProcessed++;
|
||||
return {
|
||||
id: item.id,
|
||||
sku: item.sku,
|
||||
name: item.name,
|
||||
net: item.net,
|
||||
taxRate: item.taxRate,
|
||||
track_stock: item.track_stock,
|
||||
eans: item.eans || [],
|
||||
categoryIds: item.categoryIds || [],
|
||||
photos: (item.fullHashes || []).map((h) => ({ fullHash: h })),
|
||||
brand: item.brandName ? { name: item.brandName } : null,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Build parent-child lookup for variant linking
|
||||
const articleBySku = new Map();
|
||||
const articleById = new Map();
|
||||
for (const a of fullArticles) {
|
||||
if (a.sku) articleBySku.set(a.sku, a);
|
||||
if (a.id) articleById.set(a.id, a);
|
||||
}
|
||||
|
||||
// Populate attributeValue for children from parent's children array if needed
|
||||
for (const a of fullArticles) {
|
||||
if (a.isParent && Array.isArray(a.children)) {
|
||||
for (const childRef of a.children) {
|
||||
const childObj = articleBySku.get(childRef.sku) || articleById.get(childRef.id);
|
||||
if (childObj && !childObj.attributeValue && childRef.attributeValue) {
|
||||
childObj.attributeValue = childRef.attributeValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Collect Images
|
||||
onStatus({ message: 'Sammle Bildreferenzen...' });
|
||||
const uniqueHashes = new Set();
|
||||
|
||||
// Category pictures
|
||||
for (const c of categoryTree) {
|
||||
if (c.fullHash) uniqueHashes.add(c.fullHash);
|
||||
}
|
||||
|
||||
// Brand logos
|
||||
for (const b of brands) {
|
||||
if (b.pics?.[0]?.fullHash) {
|
||||
uniqueHashes.add(b.pics[0].fullHash);
|
||||
}
|
||||
}
|
||||
|
||||
// Article pictures
|
||||
for (const a of fullArticles) {
|
||||
for (const p of a.photos || []) {
|
||||
if (p.fullHash) uniqueHashes.add(p.fullHash);
|
||||
}
|
||||
if (a.brand?.fullHash) {
|
||||
uniqueHashes.add(a.brand.fullHash);
|
||||
}
|
||||
}
|
||||
|
||||
const hashList = Array.from(uniqueHashes);
|
||||
const totalImages = hashList.length;
|
||||
onProgress({
|
||||
stage: 'images',
|
||||
imagestotal: totalImages,
|
||||
imagesdone: 0,
|
||||
percent: 60,
|
||||
});
|
||||
|
||||
onStatus({ message: `Lade ${totalImages} Bilddateien...` });
|
||||
const hashToBuffer = new Map();
|
||||
let imagesProcessed = 0;
|
||||
|
||||
await asyncPool(hashList, 5, async (fullHash) => {
|
||||
try {
|
||||
const pic = await store.pic.getImageByHash(googleAuthId, fullHash);
|
||||
if (pic?.buffer) {
|
||||
hashToBuffer.set(fullHash, pic.buffer);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Could not load image hash ${fullHash}:`, err.message);
|
||||
}
|
||||
imagesProcessed++;
|
||||
if (imagesProcessed % 25 === 0 || imagesProcessed === totalImages) {
|
||||
onProgress({
|
||||
stage: 'images',
|
||||
imagestotal: totalImages,
|
||||
imagesdone: imagesProcessed,
|
||||
percent: 60 + Math.floor((imagesProcessed / Math.max(totalImages, 1)) * 25),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Build categories.csv & place category images in ZIP
|
||||
onStatus({ message: 'Erstelle categories.csv...' });
|
||||
const categoryCsvRows = [
|
||||
['"id"', '"parentid"', '"sort"', '"name"', '"pictureFiles"'].join(';'),
|
||||
];
|
||||
|
||||
for (const c of categoryTree) {
|
||||
let picRel = '';
|
||||
if (c.fullHash && hashToBuffer.has(c.fullHash)) {
|
||||
picRel = `pictures/categories/${c.id}/${c.fullHash}.avif`;
|
||||
zip.file(picRel, hashToBuffer.get(c.fullHash));
|
||||
}
|
||||
|
||||
const row = [
|
||||
formatCsvField(c.id),
|
||||
formatCsvField(c.parentId == null ? '0' : String(c.parentId)),
|
||||
formatCsvField(c.sort ?? 0),
|
||||
formatCsvField(c.name ?? ''),
|
||||
formatCsvField(picRel),
|
||||
];
|
||||
categoryCsvRows.push(row.join(';'));
|
||||
}
|
||||
zip.file('categories.csv', categoryCsvRows.join('\r\n'));
|
||||
|
||||
// 6. Place brand images in ZIP
|
||||
for (const b of brands) {
|
||||
const hash = b.pics?.[0]?.fullHash;
|
||||
if (hash && hashToBuffer.has(hash)) {
|
||||
const rel = `pictures/brands/${b.id}/${hash}.avif`;
|
||||
zip.file(rel, hashToBuffer.get(hash));
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Build articles.csv & place article images in ZIP
|
||||
onStatus({ message: 'Erstelle articles.csv...' });
|
||||
const articleCsvHeaders = [
|
||||
'"id"',
|
||||
'"sku"',
|
||||
'"name"',
|
||||
'"net"',
|
||||
'"taxrate"',
|
||||
'"active"',
|
||||
'"stock"',
|
||||
'"ean"',
|
||||
'"weight"',
|
||||
'"track_strock"',
|
||||
'"created_at"',
|
||||
'"sort"',
|
||||
'"variation"',
|
||||
'"parentid"',
|
||||
'"is_parent"',
|
||||
'"brand"',
|
||||
'"categories"',
|
||||
'"attributes"',
|
||||
'"brandPictureFiles"',
|
||||
'"pictureFiles"',
|
||||
'"desc"',
|
||||
];
|
||||
const articleCsvRows = [articleCsvHeaders.join(';')];
|
||||
|
||||
for (const a of fullArticles) {
|
||||
// Collect article pictures
|
||||
const picPaths = [];
|
||||
for (const p of a.photos || []) {
|
||||
if (p.fullHash && hashToBuffer.has(p.fullHash)) {
|
||||
const rel = `pictures/articles/${a.id}/${p.fullHash}.avif`;
|
||||
zip.file(rel, hashToBuffer.get(p.fullHash));
|
||||
picPaths.push(rel);
|
||||
}
|
||||
}
|
||||
|
||||
// Brand image path
|
||||
let brandPicRel = '';
|
||||
const brandObj = a.brand?.id
|
||||
? brandById.get(a.brand.id)
|
||||
: a.brand?.name
|
||||
? brandByName.get(a.brand.name.trim().toLowerCase())
|
||||
: null;
|
||||
|
||||
const brandHash = brandObj?.pics?.[0]?.fullHash || a.brand?.fullHash;
|
||||
if (brandHash && hashToBuffer.has(brandHash) && (brandObj?.id || a.brand?.id)) {
|
||||
const bId = brandObj?.id || a.brand.id;
|
||||
brandPicRel = `pictures/brands/${bId}/${brandHash}.avif`;
|
||||
}
|
||||
|
||||
// Price & Tax
|
||||
const netPrice = a.prices?.[0]?.net ?? a.net ?? 0;
|
||||
const taxRate = a.prices?.[0]?.taxRate ?? a.taxRate ?? 19;
|
||||
|
||||
// Parent / Variant fields
|
||||
const isParent = Boolean(a.isParent);
|
||||
let parentId = '0';
|
||||
let variation = '';
|
||||
|
||||
if (isParent) {
|
||||
variation = a.parentAttributeName || 'Variante';
|
||||
} else if (a.parentArticleId != null || a.parentSku != null) {
|
||||
const parentObj = a.parentArticleId
|
||||
? articleById.get(a.parentArticleId)
|
||||
: articleBySku.get(a.parentSku);
|
||||
parentId = String(parentObj?.id ?? a.parentArticleId ?? 0);
|
||||
variation = a.attributeValue || 'Standard';
|
||||
}
|
||||
|
||||
// Pfand attribute
|
||||
const pfandAttr = extractPfandAttribute(a.options);
|
||||
|
||||
// Barcode / EAN
|
||||
const ean = Array.isArray(a.eans) && a.eans.length > 0 ? a.eans[0] : '';
|
||||
|
||||
// Categories
|
||||
const catStr = Array.isArray(a.categoryIds) ? a.categoryIds.join(';') : '';
|
||||
|
||||
const row = [
|
||||
formatCsvField(a.id),
|
||||
formatCsvField(a.sku),
|
||||
formatCsvField(a.name ?? ''),
|
||||
formatCsvField(formatGermanFloat(netPrice)),
|
||||
formatCsvField(formatGermanFloat(taxRate, '19')),
|
||||
formatCsvField('1'), // active
|
||||
formatCsvField(a.stock ?? 0),
|
||||
formatCsvField(ean),
|
||||
formatCsvField(formatGermanFloat(a.weight ?? 0)),
|
||||
formatCsvField(a.track_stock ? '1' : '0'),
|
||||
formatCsvField(a.createdAt || new Date().toISOString()),
|
||||
formatCsvField(a.sort ?? 0),
|
||||
formatCsvField(variation),
|
||||
formatCsvField(parentId),
|
||||
formatCsvField(isParent ? '1' : '0'),
|
||||
formatCsvField(a.brand?.name || ''),
|
||||
formatCsvField(catStr),
|
||||
formatCsvField(pfandAttr),
|
||||
formatCsvField(brandPicRel),
|
||||
formatCsvField(picPaths.join(';')),
|
||||
formatCsvField(a.desc || ''),
|
||||
];
|
||||
articleCsvRows.push(row.join(';'));
|
||||
}
|
||||
zip.file('articles.csv', articleCsvRows.join('\r\n'));
|
||||
|
||||
// 8. Generate ZIP buffer
|
||||
onStatus({ message: 'Erstelle ZIP-Archiv...' });
|
||||
onProgress({
|
||||
stage: 'packaging',
|
||||
percent: 92,
|
||||
});
|
||||
|
||||
const zipBuffer = await zip.generateAsync({
|
||||
type: 'nodebuffer',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 },
|
||||
});
|
||||
|
||||
onProgress({
|
||||
stage: 'packaging',
|
||||
percent: 100,
|
||||
});
|
||||
|
||||
const filename = `company-${companyId}-export.zip`;
|
||||
const stats = {
|
||||
categories: categoryTree.length,
|
||||
brands: brands.length,
|
||||
articles: fullArticles.length,
|
||||
images: hashToBuffer.size,
|
||||
zipSize: zipBuffer.length,
|
||||
};
|
||||
|
||||
log.info(`Company export complete: ${filename} (${zipBuffer.length} bytes, ${stats.articles} articles, ${stats.images} images)`);
|
||||
|
||||
return {
|
||||
zipBuffer,
|
||||
stats,
|
||||
filename,
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
Reference in New Issue
Block a user