// MARK: - Imports // #region Imports import JSZip from 'jszip'; import { getStore, getProvider } from '../db/store.js'; import { createLogger } from '../util/logger.js'; // #endregion const log = createLogger('export'); const UTF8_BOM = '\uFEFF'; // 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 value as an Excel string formula (e.g. ="4270001588593") so that * spreadsheet tools treat it strictly as text rather than converting to a number / scientific notation. * Empty or null values return '""'. * * @param {any} val * @returns {string} */ function formatCsvStringFormula(val) { if (val == null) return '""'; const str = String(val).trim(); if (str === '') return '""'; 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} 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} items * @param {number} concurrency * @param {(item: T, index: number) => Promise} fn * @returns {Promise>} */ 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 catName = c.name || (c.parentId == null ? 'Haupt' : `Kategorie ${c.id}`); const row = [ formatCsvField(c.id), formatCsvField(c.parentId == null ? '0' : String(c.parentId)), formatCsvField(c.sort ?? 0), formatCsvField(catName), formatCsvField(picRel), ]; categoryCsvRows.push(row.join(';')); } zip.file('categories.csv', UTF8_BOM + 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"', '"supplieer"', '"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(';') : ''; // Suppliers: :::: separated by | let supplierStr = ''; if (Array.isArray(a.suppliers) && a.suppliers.length > 0) { supplierStr = a.suppliers .map((s) => { const sName = String(s.supplierName || '').replace(/[:|]/g, ' ').trim(); const sSku = String(s.sku || '').replace(/[:|]/g, ' ').trim(); const vpe = (s.pu != null && Number(s.pu) > 1) ? Number(s.pu) : 0; const sTaxRate = s.price?.taxRate != null ? Number(s.price.taxRate) : 0; const netPrice = Number(s.price?.net || 0).toFixed(2); return `${sName}:${sSku}:${vpe}:${sTaxRate}:${netPrice}`; }) .join('|'); } const row = [ formatCsvField(a.id), formatCsvStringFormula(a.sku), formatCsvField(a.name ?? ''), formatCsvField(formatGermanFloat(netPrice)), formatCsvField(formatGermanFloat(taxRate, '19')), formatCsvField('1'), // active formatCsvField(a.stock ?? 0), formatCsvStringFormula(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(supplierStr), formatCsvField(a.desc || ''), ]; articleCsvRows.push(row.join(';')); } zip.file('articles.csv', UTF8_BOM + articleCsvRows.join('\r\n')); // 8. Orders / Manifests (if present) let exportedReceiptsCount = 0; try { const orderListRes = await store.order.list(googleAuthId, { offset: 0, limit: 1 }); const totalOrders = orderListRes?.total ?? (Array.isArray(orderListRes) ? orderListRes.length : 0); if (totalOrders > 0) { onStatus({ message: `Exportiere ${totalOrders} Aufträge / Belege...` }); // Prefetch payment types and shipping types by manifest ID const paymentMap = new Map(); const shippingMap = new Map(); try { const provider = await getProvider(); const isSqlite = provider?.dialect === 'sqlite3'; const typeCol = isSqlite ? '"type"' : '[type]'; const [paymentsRes, shippingsRes] = await Promise.all([ provider.query(` SELECT p.manifest_id AS manifestId, p.${typeCol} AS type FROM payment p INNER JOIN manifest m ON m.id = p.manifest_id WHERE m.company_id = @companyId ORDER BY p.id ASC `, { companyId }), provider.query(` SELECT s.manifest_id AS manifestId, s.${typeCol} AS type FROM shipping s INNER JOIN manifest m ON m.id = s.manifest_id WHERE m.company_id = @companyId ORDER BY s.id ASC `, { companyId }), ]); for (const row of paymentsRes.rows || []) { if (!paymentMap.has(row.manifestId)) { paymentMap.set(row.manifestId, row.type); } } for (const row of shippingsRes.rows || []) { if (!shippingMap.has(row.manifestId)) { shippingMap.set(row.manifestId, row.type); } } } catch (err) { log.warn('Could not prefetch payment/shipping types:', err.message); } const entityMap = new Map(); try { const entities = await store.entity.list(googleAuthId); for (const ent of entities) { entityMap.set(ent.id, ent); } } catch (err) { log.warn('Could not prefetch entities for order export:', err.message); } const manifestCsvHeaders = [ '"id"', '"nr"', '"extnr"', '"created_at"', '"customerNr"', '"customerName"', '"paymentType"', '"shippingType"', '"payed"', '"shipped"', ]; const manifestItemCsvHeaders = [ '"id"', '"manifestid"', '"articleid"', '"name"', '"amount"', '"amountShipped"', '"net"', '"taxrate"', '"sort"', ]; const manifestCsvRows = [manifestCsvHeaders.join(';')]; const manifestItemCsvRows = [manifestItemCsvHeaders.join(';')]; let orderOffset = 0; const orderLimit = 200; let itemSeq = 0; while (true) { const batchRes = await store.order.list(googleAuthId, { offset: orderOffset, limit: orderLimit }); const batch = Array.isArray(batchRes) ? batchRes : (batchRes?.orders || []); if (batch.length === 0) break; for (const ord of batch) { const payType = paymentMap.get(ord.id) || ord.payment?.type || ord.payments?.[0]?.type || 'Bar'; const shipType = shippingMap.get(ord.id) || ord.shipping?.type || ord.shippings?.[0]?.type || 'Selbstabholer'; const entity = ord.entity || (ord.entityId ? entityMap.get(ord.entityId) : null); const customerNr = ord.customerNr || entity?.number || ord.customer?.number || ''; const customerName = ord.customerName || entity?.name || [entity?.firstName, entity?.lastName].filter(Boolean).join(' ') || [ord.customerFirstName, ord.customerLastName].filter(Boolean).join(' ') || ''; manifestCsvRows.push([ formatCsvField(ord.id), formatCsvField(ord.number), formatCsvField(ord.externalNumber || ''), formatCsvField(ord.createdAt || ord.date || new Date().toISOString()), formatCsvField(customerNr), formatCsvField(customerName), formatCsvField(payType), formatCsvField(shipType), formatCsvField(ord.isPaid || ord.paid ? '1' : '0'), formatCsvField(ord.isShipped || ord.shipped ? '1' : '0'), ].join(';')); for (const line of ord.lines || []) { const artId = (line.sku && articleBySku.get(line.sku)?.id) || line.articleId || ''; manifestItemCsvRows.push([ formatCsvField(line.id || ++itemSeq), formatCsvField(ord.id), formatCsvField(artId), formatCsvField(line.name || ''), formatCsvField(line.amount ?? 1), formatCsvField(ord.isShipped || ord.shipped ? (line.amount ?? 1) : 0), formatCsvField(formatGermanFloat(line.net ?? 0)), formatCsvField(formatGermanFloat(line.taxrate ?? 19)), formatCsvField(line.sort ?? 0), ].join(';')); } } orderOffset += batch.length; if (batch.length < orderLimit || orderOffset >= totalOrders) { break; } } if (manifestCsvRows.length > 1) { zip.file('manifests.csv', UTF8_BOM + manifestCsvRows.join('\r\n')); zip.file('manifestitems.csv', UTF8_BOM + manifestItemCsvRows.join('\r\n')); exportedReceiptsCount = manifestCsvRows.length - 1; } } } catch (orderErr) { log.warn('Could not export orders / manifests:', orderErr.message); } // 9. 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, orders: exportedReceiptsCount, zipSize: zipBuffer.length, }; log.info(`Company export complete: ${filename} (${zipBuffer.length} bytes, ${stats.articles} articles, ${stats.images} images)`); return { zipBuffer, stats, filename, }; } // #endregion