// MARK: - Imports // #region Imports import fs from 'fs'; import { cleanValue, parseGermanFloat } from './csvParser.js'; import { setupVariantAttributes } from './variantImporter.js'; import { setupPfandArticles, linkArticlePfand, extractPfandValue } from './pfandImporter.js'; import { computeSkeleHash } from './imageHasher.js'; import { createLogger } from '../../util/logger.js'; // #endregion const log = createLogger('import'); // MARK: - Article Importer // #region Article Importer /** * Imports articles (insert or update), setting prices, categories, barcodes, brands, * variants, pfand deposit options, and pictures in a single unified loop. * * @param {object} params * @param {object} params.store - Store singleton * @param {number} params.googleAuthId * @param {Array>} params.includedArticles * @param {Array} [params.articleImageTasks=[]] * @param {Map} params.jtlCatIdToDashCatId * @param {number} params.hauptDashId * @param {Map} params.brandNameToId * @param {object} params.progressTracker * @returns {Promise<{ * jtlArtIdToSku: Map, * articlesImported: number, * articlesUpdated: number, * variantsLinked: number, * parentsCount: number, * parentSkus: Set, * pfandArticlesCreated: number, * pfandArticlesUpdated: number, * pfandOptionsLinked: number, * totalImagesImported: number * }>} */ export async function importArticles({ store, googleAuthId, includedArticles, articleImageTasks = [], jtlCatIdToDashCatId, hauptDashId, brandNameToId, progressTracker, }) { // 1. Group article image tasks by SKU const articleImagesBySku = new Map(); for (const task of articleImageTasks) { if (!task.sku) continue; if (!articleImagesBySku.has(task.sku)) { articleImagesBySku.set(task.sku, []); } articleImagesBySku.get(task.sku).push(task); } // 2. Setup Pfand category and Pfand articles upfront (e.g. PFAND-0.08, PFAND-0.15, etc.) const pfandSetup = await setupPfandArticles({ store, googleAuthId, hauptDashId, includedArticles, }); // 3. Setup variant attributes resolver const variantSetup = await setupVariantAttributes({ store, googleAuthId }); // 4. Pre-scan articles to identify parents and map parent Pfand / parent rows const parentJtlIds = new Set(); const parentPfandMap = new Map(); const parentRowMap = new Map(); const jtlArtIdToSku = new Map(); for (const a of includedArticles) { const id = cleanValue(a.id ?? a.kArtikel); const pid = cleanValue(a.parentid ?? a.kVaterArtikel); const sku = cleanValue(a.sku ?? a.cArtNr); const isParent = a.is_parent === '1' || a.nIstVater === '1'; if (id && sku) { jtlArtIdToSku.set(id, sku); } if (pid && pid !== '0') { parentJtlIds.add(pid); } if (isParent && id) { parentJtlIds.add(id); } } // Identify parent rows and their Pfand values for (const a of includedArticles) { const id = cleanValue(a.id ?? a.kArtikel); if (id && parentJtlIds.has(id)) { parentRowMap.set(id, a); const val = extractPfandValue(a.attributes); if (val != null) { parentPfandMap.set(id, val); } } } // 5. Sort articles so parents come first, followed by children and standalone articles const sortedArticles = [...includedArticles].sort((a, b) => { const aId = cleanValue(a.id ?? a.kArtikel); const bId = cleanValue(b.id ?? b.kArtikel); const aIsParent = parentJtlIds.has(aId); const bIsParent = parentJtlIds.has(bId); if (aIsParent && !bIsParent) return -1; if (!aIsParent && bIsParent) return 1; return 0; }); const parentSkus = new Set(); const parentTargetAttrIds = new Map(); // parentSku -> targetAttrId const parentUsedValues = new Map(); // parentSku -> Set let articlesImported = 0; let articlesUpdated = 0; let variantsLinked = 0; let pfandOptionsLinked = 0; let articlePicturesImported = 0; // 6. Unified single loop over all articles for (const a of sortedArticles) { const jtlArtId = String(a.id ?? a.kArtikel); const sku = cleanValue(a.sku ?? a.cArtNr); if (!sku) { progressTracker.counts.articlesdone++; continue; } jtlArtIdToSku.set(jtlArtId, sku); const isParent = parentJtlIds.has(jtlArtId); if (isParent) { parentSkus.add(sku); } // --- Basic article fields --- let name = cleanValue(a.name ?? a.cName); let desc = cleanValue(a.desc ?? a.descriptions); if (!name && desc) { try { const parsed = JSON.parse(desc); const item = Array.isArray(parsed) ? parsed[0] : parsed; if (item?.cName || item?.name) name = String(item.cName || item.name).trim(); if (item?.cBeschreibung || item?.desc) desc = String(item.cBeschreibung || item.desc).trim(); } catch {} } if (!name) name = `Artikel ${sku}`; // Category mapping const assignedDashCatIds = []; const rawCatStr = String(a.categories || '').trim(); if (rawCatStr) { let parsedIds = []; if (rawCatStr.startsWith('[') || rawCatStr.startsWith('{')) { try { const raw = JSON.parse(rawCatStr); parsedIds = (Array.isArray(raw) ? raw : [raw]).map((it) => String(it.id ?? it.kKategorie ?? it)); } catch { parsedIds = rawCatStr.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean); } } else { parsedIds = rawCatStr.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean); } for (const jtlCId of parsedIds) { if (jtlCatIdToDashCatId.has(jtlCId)) { assignedDashCatIds.push(jtlCatIdToDashCatId.get(jtlCId)); } } } const primaryCatId = assignedDashCatIds[0] || hauptDashId; const rawTrack = cleanValue(a.track_strock ?? a.track_stock ?? a.cLagerAktiv); const trackStock = (rawTrack === 'Y' || rawTrack === '1' || rawTrack === 'true') ? 1 : 0; const netPrice = parseGermanFloat(a.net ?? a.fVKNetto, 0); const taxRate = a.taxrate != null && String(a.taxrate).trim() !== '' ? parseGermanFloat(a.taxrate, 19) : (a.kSteuerklasse === '3' ? 7 : 19); const barcode = cleanValue(a.ean ?? a.cBarcode); const hersteller = cleanValue(a.brand ?? a.Hersteller); const brandId = hersteller ? (brandNameToId.get(hersteller.toLowerCase()) || null) : null; let existingArticle = null; try { const res = await store.article.getBySku(googleAuthId, sku); existingArticle = res.article; } catch { existingArticle = null; } try { if (existingArticle) { await store.article.updateBySku(googleAuthId, sku, { name, track_stock: trackStock, desc: desc || undefined, }); await store.article.setPricesBySku(googleAuthId, sku, [ { net: netPrice, tax: taxRate }, ]); if (assignedDashCatIds.length > 0) { await store.article.setCategoryIdsBySku(googleAuthId, sku, assignedDashCatIds); } if (barcode) { try { const currentEans = existingArticle.eans || []; if (!currentEans.includes(barcode)) { await store.article.setEansBySku(googleAuthId, sku, [...currentEans, barcode]); } } catch (eanErr) { log.warn(`Could not set EAN for ${sku}:`, eanErr.message); } } if (brandId && existingArticle.brand?.id !== brandId) { try { await store.article.setBrandIdBySku(googleAuthId, sku, brandId); } catch (bErr) { log.warn(`Could not set brand for ${sku}:`, bErr.message); } } articlesUpdated++; } else { await store.article.createUnderParent({ googleAuthId, parentId: primaryCatId, name, sku, track_stock: trackStock, desc: desc || undefined, }); await store.article.setPricesBySku(googleAuthId, sku, [ { net: netPrice, tax: taxRate }, ]); if (assignedDashCatIds.length > 1) { await store.article.setCategoryIdsBySku(googleAuthId, sku, assignedDashCatIds); } if (barcode) { try { await store.article.setEansBySku(googleAuthId, sku, [barcode]); } catch (eanErr) { log.warn(`Could not set EAN for ${sku}:`, eanErr.message); } } if (brandId) { try { await store.article.setBrandIdBySku(googleAuthId, sku, brandId); } catch (bErr) { log.warn(`Could not set brand for ${sku}:`, bErr.message); } } articlesImported++; } } catch (err) { log.warn(`Failed to save article ${sku} ("${name}"):`, err.message); } // --- Variants handling --- if (isParent) { const rawAttrName = cleanValue(a.variation ?? a.kEigenschaftKombi); const targetAttrId = await variantSetup.resolveAttributeId(rawAttrName || 'Variante'); if (targetAttrId) { parentTargetAttrIds.set(sku, targetAttrId); try { await store.article.toggleParent(googleAuthId, sku, { isParent: true, attributeNameId: targetAttrId, }); } catch (err) { log.warn(`Could not toggle parent for ${sku}:`, err.message); } } } else { const parentJtlId = cleanValue(a.parentid ?? a.kVaterArtikel); if (parentJtlId && parentJtlId !== '0') { const parentSku = jtlArtIdToSku.get(parentJtlId); if (parentSku && parentSku !== sku) { const targetAttrId = parentTargetAttrIds.get(parentSku); if (!parentUsedValues.has(parentSku)) { parentUsedValues.set(parentSku, new Set()); } const usedSet = parentUsedValues.get(parentSku); let val = cleanValue(a.variation ?? a.kEigenschaftKombi) || 'Standard'; if (usedSet.has(val.toLowerCase())) { val = `${val} (${sku})`; } usedSet.add(val.toLowerCase()); try { if (targetAttrId) { await store.article.attachChild(googleAuthId, parentSku, { sku, value: val, reparent: true, }); variantsLinked++; } else { await store.article.setParentBySku(googleAuthId, sku, parentSku); variantsLinked++; } } catch (err) { log.warn(`Failed to attach child ${sku} to parent ${parentSku}:`, err.message); } } } } // --- Pfand handling (only for non-parents) --- if (!isParent) { const parentJtlId = cleanValue(a.parentid ?? a.kVaterArtikel); let pfandVal = extractPfandValue(a.attributes); if (pfandVal == null && parentJtlId && parentJtlId !== '0') { if (parentPfandMap.has(parentJtlId)) { pfandVal = parentPfandMap.get(parentJtlId); } } if (pfandVal != null) { const linked = await linkArticlePfand({ store, googleAuthId, sku, pfandVal, }); if (linked) pfandOptionsLinked++; } } // --- Pictures handling for this article --- const imagesForThisArt = articleImagesBySku.get(sku); if (imagesForThisArt && imagesForThisArt.length > 0) { for (const imgTask of imagesForThisArt) { try { const imgBuf = fs.readFileSync(imgTask.fullPath); await store.article.addImage(googleAuthId, sku, imgBuf); articlePicturesImported++; const skele = await computeSkeleHash(imgBuf); progressTracker.emitSkeleHash(skele?.base62 || null); } catch (imgErr) { log.warn(`Article image add failed for ${sku} (${imgTask.relPath}):`, imgErr.message); } progressTracker.counts.imagesdone++; progressTracker.emitProgress(); } } progressTracker.counts.articlesdone++; progressTracker.emitProgress(); } progressTracker.flushSkeleHashes(); progressTracker.emitProgress(true); log.info(`Articles complete: New: ${articlesImported}, Updated: ${articlesUpdated}, Variants: ${variantsLinked}, Pfand: ${pfandOptionsLinked}, Pictures: ${articlePicturesImported}`); return { jtlArtIdToSku, articlesImported, articlesUpdated, variantsLinked, parentsCount: parentSkus.size, parentSkus, pfandArticlesCreated: pfandSetup.pfandArticlesCreated, pfandArticlesUpdated: pfandSetup.pfandArticlesUpdated, pfandOptionsLinked, totalImagesImported: articlePicturesImported, }; } // #endregion