diff --git a/src/services/companyExport.js b/src/services/companyExport.js index 14b31f1..b3a6bea 100644 --- a/src/services/companyExport.js +++ b/src/services/companyExport.js @@ -1,12 +1,14 @@ // MARK: - Imports // #region Imports import JSZip from 'jszip'; -import { getStore } from '../db/store.js'; +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 @@ -22,6 +24,21 @@ function formatCsvField(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"). * @@ -297,16 +314,17 @@ export async function executeCompanyExport({ 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(c.name ?? ''), + formatCsvField(catName), formatCsvField(picRel), ]; categoryCsvRows.push(row.join(';')); } - zip.file('categories.csv', categoryCsvRows.join('\r\n')); + zip.file('categories.csv', UTF8_BOM + categoryCsvRows.join('\r\n')); // 6. Place brand images in ZIP for (const b of brands) { @@ -399,13 +417,13 @@ export async function executeCompanyExport({ const row = [ formatCsvField(a.id), - formatCsvField(a.sku), + formatCsvStringFormula(a.sku), formatCsvField(a.name ?? ''), formatCsvField(formatGermanFloat(netPrice)), formatCsvField(formatGermanFloat(taxRate, '19')), formatCsvField('1'), // active formatCsvField(a.stock ?? 0), - formatCsvField(ean), + formatCsvStringFormula(ean), formatCsvField(formatGermanFloat(a.weight ?? 0)), formatCsvField(a.track_stock ? '1' : '0'), formatCsvField(a.createdAt || new Date().toISOString()), @@ -422,9 +440,136 @@ export async function executeCompanyExport({ ]; articleCsvRows.push(row.join(';')); } - zip.file('articles.csv', articleCsvRows.join('\r\n')); + zip.file('articles.csv', UTF8_BOM + articleCsvRows.join('\r\n')); - // 8. Generate ZIP buffer + // 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 manifestCsvHeaders = [ + '"id"', + '"nr"', + '"extnr"', + '"created_at"', + '"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'; + + manifestCsvRows.push([ + formatCsvField(ord.id), + formatCsvField(ord.number), + formatCsvField(ord.externalNumber || ''), + formatCsvField(ord.createdAt || ord.date || new Date().toISOString()), + 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', @@ -448,6 +593,7 @@ export async function executeCompanyExport({ brands: brands.length, articles: fullArticles.length, images: hashToBuffer.size, + orders: exportedReceiptsCount, zipSize: zipBuffer.length, };