u
This commit is contained in:
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