diff --git a/src/services/companyExport.js b/src/services/companyExport.js index b3a6bea..166fa8e 100644 --- a/src/services/companyExport.js +++ b/src/services/companyExport.js @@ -358,6 +358,7 @@ export async function executeCompanyExport({ '"attributes"', '"brandPictureFiles"', '"pictureFiles"', + '"supplieer"', '"desc"', ]; const articleCsvRows = [articleCsvHeaders.join(';')]; @@ -415,6 +416,21 @@ export async function executeCompanyExport({ // 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), @@ -436,6 +452,7 @@ export async function executeCompanyExport({ formatCsvField(pfandAttr), formatCsvField(brandPicRel), formatCsvField(picPaths.join(';')), + formatCsvField(supplierStr), formatCsvField(a.desc || ''), ]; articleCsvRows.push(row.join(';')); diff --git a/src/services/companyImport.js b/src/services/companyImport.js index 9b3163f..e442735 100644 --- a/src/services/companyImport.js +++ b/src/services/companyImport.js @@ -5,7 +5,7 @@ import path from 'path'; import { getStore } from '../db/store.js'; import { createLogger } from '../util/logger.js'; -import { cleanValue, parseGermanFloat, parseCsvRecords } from './companyImport/csvParser.js'; +import { cleanValue, parseGermanFloat, parseCsvRecords, parseSuppliers } from './companyImport/csvParser.js'; import { computeSkeleHash } from './companyImport/imageHasher.js'; import { normalizeAttributeName, extractVariantValue } from './companyImport/variantHelpers.js'; import { createProgressTracker } from './companyImport/progressTracker.js'; @@ -25,6 +25,7 @@ export { cleanValue, parseGermanFloat, parseCsvRecords, + parseSuppliers, computeSkeleHash, normalizeAttributeName, extractVariantValue, @@ -208,6 +209,7 @@ export async function executeCompanyImport({ pfandArticlesCreated: articlesResult.pfandArticlesCreated, pfandArticlesUpdated: articlesResult.pfandArticlesUpdated, pfandOptionsLinked: articlesResult.pfandOptionsLinked, + suppliersLinked: articlesResult.suppliersLinked || 0, articlePictures: articlesResult.totalImagesImported, ordersTotal: totalReceiptsCount, ordersImported: ordersResult.ordersImported, diff --git a/src/services/companyImport/articleImporter.js b/src/services/companyImport/articleImporter.js index 3674977..f9e5058 100644 --- a/src/services/companyImport/articleImporter.js +++ b/src/services/companyImport/articleImporter.js @@ -1,7 +1,7 @@ // MARK: - Imports // #region Imports import fs from 'fs'; -import { cleanValue, parseGermanFloat } from './csvParser.js'; +import { cleanValue, parseGermanFloat, parseSuppliers } from './csvParser.js'; import { setupVariantAttributes } from './variantImporter.js'; import { setupPfandArticles, linkArticlePfand, extractPfandValue } from './pfandImporter.js'; import { computeSkeleHash } from './imageHasher.js'; @@ -70,6 +70,34 @@ export async function importArticles({ // 3. Setup variant attributes resolver const variantSetup = await setupVariantAttributes({ store, googleAuthId }); + // 3b. Setup suppliers lookup and helper + const supplierByName = new Map(); + try { + const existingSuppliers = await store.supplier.list(googleAuthId); + for (const s of existingSuppliers || []) { + if (s.name) supplierByName.set(s.name.trim().toLowerCase(), s); + } + } catch (sErr) { + log.warn('Could not list suppliers upfront:', sErr.message); + } + + async function getOrCreateSupplier(name) { + const cleaned = (name || '').trim(); + if (!cleaned) return null; + const key = cleaned.toLowerCase(); + if (supplierByName.has(key)) { + return supplierByName.get(key); + } + try { + const created = await store.supplier.create(googleAuthId, { name: cleaned }); + supplierByName.set(key, created); + return created; + } catch (err) { + log.warn(`Could not create supplier "${cleaned}":`, err.message); + return null; + } + } + // 4. Pre-scan articles to identify parents and map parent Pfand / parent rows const parentJtlIds = new Set(); const parentPfandMap = new Map(); @@ -124,6 +152,7 @@ export async function importArticles({ let articlesUpdated = 0; let variantsLinked = 0; let pfandOptionsLinked = 0; + let suppliersLinked = 0; let articlePicturesImported = 0; // 6. Unified single loop over all articles @@ -344,6 +373,82 @@ export async function importArticles({ } } + // --- Suppliers handling (only for non-parents) --- + if (!isParent) { + const rawSuppliers = cleanValue(a.supplieer ?? a.supplier); + const parsedSuppliers = parseSuppliers(rawSuppliers); + + if (parsedSuppliers.length > 0) { + const existingByEntityId = new Map(); + for (const asup of (existingArticle?.suppliers || [])) { + if (asup.entityId != null) { + existingByEntityId.set(asup.entityId, asup); + } + } + + const assignedIds = []; + for (let i = 0; i < parsedSuppliers.length; i++) { + const sup = parsedSuppliers[i]; + try { + const supplierEntity = await getOrCreateSupplier(sup.supplierName); + if (!supplierEntity?.id) continue; + + const pu = sup.vpe > 0 ? sup.vpe : 1; + const netPrice = sup.netPurchasePrice; + const taxRate = sup.taxRate; + const sSku = sup.supplierSku || null; + + const existingAssoc = existingByEntityId.get(supplierEntity.id); + if (existingAssoc) { + await store.articlesupplier.update(googleAuthId, sku, existingAssoc.id, { + sku: sSku, + pu, + netPrice, + taxRate, + }); + assignedIds.push(existingAssoc.id); + } else { + const created = await store.articlesupplier.create(googleAuthId, sku, { + entityId: supplierEntity.id, + sku: sSku, + pu, + netPrice, + taxRate, + }); + assignedIds.push(created.id); + } + } catch (supErr) { + log.warn(`Failed to process supplier "${sup.supplierName}" for ${sku}:`, supErr.message); + } + } + + // Preserve primary / secondary ordering + if (assignedIds.length > 0) { + try { + const allExisting = new Map(); + for (const asup of (existingArticle?.suppliers || [])) { + allExisting.set(asup.id, asup); + } + for (const id of assignedIds) { + allExisting.set(id, true); + } + const orderIds = [...assignedIds]; + for (const id of allExisting.keys()) { + if (!orderIds.includes(id)) { + orderIds.push(id); + } + } + if (orderIds.length > 1) { + await store.articlesupplier.order(googleAuthId, sku, { ids: orderIds }); + } + suppliersLinked += assignedIds.length; + } catch (orderErr) { + log.warn(`Failed to order suppliers for ${sku}:`, orderErr.message); + } + } + } + } + // --- Pictures handling for this article --- const imagesForThisArt = articleImagesBySku.get(sku); if (imagesForThisArt && imagesForThisArt.length > 0) { @@ -369,7 +474,7 @@ export async function importArticles({ progressTracker.flushSkeleHashes(); progressTracker.emitProgress(true); - log.info(`Articles complete: New: ${articlesImported}, Updated: ${articlesUpdated}, Variants: ${variantsLinked}, Pfand: ${pfandOptionsLinked}, Pictures: ${articlePicturesImported}`); + log.info(`Articles complete: New: ${articlesImported}, Updated: ${articlesUpdated}, Variants: ${variantsLinked}, Pfand: ${pfandOptionsLinked}, Suppliers: ${suppliersLinked}, Pictures: ${articlePicturesImported}`); return { jtlArtIdToSku, @@ -381,6 +486,7 @@ export async function importArticles({ pfandArticlesCreated: pfandSetup.pfandArticlesCreated, pfandArticlesUpdated: pfandSetup.pfandArticlesUpdated, pfandOptionsLinked, + suppliersLinked, totalImagesImported: articlePicturesImported, }; } diff --git a/src/services/companyImport/csvParser.js b/src/services/companyImport/csvParser.js index e132f2b..8be2f25 100644 --- a/src/services/companyImport/csvParser.js +++ b/src/services/companyImport/csvParser.js @@ -125,4 +125,31 @@ export function parseCsvRecords(text) { return records; } +/** + * Parses the pipe/colon delimited supplieer column into structured supplier objects. + * Format: :::: + * Delimited by '|' between suppliers and ':' between fields. + * The first entry is always the primary / standard supplier. + * + * @param {string} cellValue + * @returns {Array<{ isPrimary: boolean, supplierName: string, supplierSku: string, vpe: number, taxRate: number, netPurchasePrice: number }>} + */ +export function parseSuppliers(cellValue) { + if (!cellValue || typeof cellValue !== 'string' || cellValue.trim() === '') return []; + + return cellValue.split('|').map((entry, index) => { + const [supplierName, supplierSku, vpe, taxRate, netPrice] = entry.split(':'); + + return { + isPrimary: index === 0, // First entry is always the standard supplier + supplierName: (supplierName || '').trim(), + supplierSku: (supplierSku || '').trim(), + vpe: Number(vpe) || 0, + taxRate: Number(taxRate) || 0, + netPurchasePrice: Number(netPrice) || 0, + }; + }).filter((s) => s.supplierName); +} + // #endregion +