This commit is contained in:
seb
2026-10-14 14:45:45 +02:00
parent 37c58648db
commit 90b782e500
7 changed files with 714 additions and 1 deletions

View File

@@ -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` |

View File

@@ -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 }`

View 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`._

View 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`._

View File

@@ -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',

View File

@@ -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}:

View 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