389 lines
14 KiB
JavaScript
389 lines
14 KiB
JavaScript
// 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<Record<string, string>>} params.includedArticles
|
|
* @param {Array<object>} [params.articleImageTasks=[]]
|
|
* @param {Map<string, number>} params.jtlCatIdToDashCatId
|
|
* @param {number} params.hauptDashId
|
|
* @param {Map<string, number>} params.brandNameToId
|
|
* @param {object} params.progressTracker
|
|
* @returns {Promise<{
|
|
* jtlArtIdToSku: Map<string, string>,
|
|
* articlesImported: number,
|
|
* articlesUpdated: number,
|
|
* variantsLinked: number,
|
|
* parentsCount: number,
|
|
* parentSkus: Set<string>,
|
|
* 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<lowercaseValue>
|
|
|
|
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
|