u
This commit is contained in:
File diff suppressed because it is too large
Load Diff
55
src/services/companyImport/articleImageImporter.js
Normal file
55
src/services/companyImport/articleImageImporter.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// MARK: - Imports
|
||||
// #region Imports
|
||||
import fs from 'fs';
|
||||
import { computeSkeleHash } from './imageHasher.js';
|
||||
import { createLogger } from '../../util/logger.js';
|
||||
// #endregion
|
||||
|
||||
const log = createLogger('import');
|
||||
|
||||
// MARK: - Article Image Importer
|
||||
// #region Article Image Importer
|
||||
|
||||
/**
|
||||
* Imports article images and emits their skelehashes.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.store - Store singleton
|
||||
* @param {number} params.googleAuthId
|
||||
* @param {Array<object>} params.articleImageTasks
|
||||
* @param {object} params.progressTracker
|
||||
* @returns {Promise<{ totalImagesImported: number }>}
|
||||
*/
|
||||
export async function importArticleImages({
|
||||
store,
|
||||
googleAuthId,
|
||||
articleImageTasks,
|
||||
progressTracker,
|
||||
}) {
|
||||
let totalImagesImported = 0;
|
||||
|
||||
for (const task of articleImageTasks) {
|
||||
try {
|
||||
const imgBuf = fs.readFileSync(task.fullPath);
|
||||
await store.article.addImage(googleAuthId, task.sku, imgBuf);
|
||||
totalImagesImported++;
|
||||
|
||||
const skele = await computeSkeleHash(imgBuf);
|
||||
progressTracker.emitSkeleHash(skele?.base62 || null);
|
||||
} catch (err) {
|
||||
log.warn(`Article image add failed for ${task.sku} (${task.relPath}):`, err.message);
|
||||
}
|
||||
progressTracker.counts.imagesdone++;
|
||||
progressTracker.emitProgress();
|
||||
}
|
||||
|
||||
progressTracker.flushSkeleHashes();
|
||||
progressTracker.emitProgress(true);
|
||||
log.info(`Article pictures complete: ${totalImagesImported}/${articleImageTasks.length}`);
|
||||
|
||||
return {
|
||||
totalImagesImported,
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
190
src/services/companyImport/articleImporter.js
Normal file
190
src/services/companyImport/articleImporter.js
Normal file
@@ -0,0 +1,190 @@
|
||||
// MARK: - Imports
|
||||
// #region Imports
|
||||
import { cleanValue, parseGermanFloat } from './csvParser.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, and brands.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.store - Store singleton
|
||||
* @param {number} params.googleAuthId
|
||||
* @param {Array<Record<string, string>>} params.includedArticles
|
||||
* @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 }>}
|
||||
*/
|
||||
export async function importArticles({
|
||||
store,
|
||||
googleAuthId,
|
||||
includedArticles,
|
||||
jtlCatIdToDashCatId,
|
||||
hauptDashId,
|
||||
brandNameToId,
|
||||
progressTracker,
|
||||
}) {
|
||||
const jtlArtIdToSku = new Map();
|
||||
let articlesImported = 0;
|
||||
let articlesUpdated = 0;
|
||||
|
||||
for (const a of includedArticles) {
|
||||
const jtlArtId = String(a.kArtikel);
|
||||
const sku = cleanValue(a.cArtNr);
|
||||
if (!sku) {
|
||||
progressTracker.counts.articlesdone++;
|
||||
continue;
|
||||
}
|
||||
|
||||
jtlArtIdToSku.set(jtlArtId, sku);
|
||||
|
||||
let name = cleanValue(a.cName);
|
||||
let desc = cleanValue(a.descriptions);
|
||||
|
||||
if (!name && desc) {
|
||||
try {
|
||||
const parsed = JSON.parse(desc);
|
||||
const item = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
if (item?.cName) name = String(item.cName).trim();
|
||||
if (item?.cBeschreibung) desc = String(item.cBeschreibung).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.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 trackStock = a.cLagerAktiv === 'Y' ? 1 : 0;
|
||||
const netPrice = parseGermanFloat(a.fVKNetto, 0);
|
||||
const taxRate = a.kSteuerklasse === '3' ? 7 : 19;
|
||||
const barcode = cleanValue(a.cBarcode);
|
||||
|
||||
const hersteller = cleanValue(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);
|
||||
}
|
||||
|
||||
progressTracker.counts.articlesdone++;
|
||||
progressTracker.emitProgress();
|
||||
}
|
||||
|
||||
progressTracker.emitProgress(true);
|
||||
log.info(`Articles complete: New: ${articlesImported}, Updated: ${articlesUpdated}`);
|
||||
|
||||
return {
|
||||
jtlArtIdToSku,
|
||||
articlesImported,
|
||||
articlesUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
91
src/services/companyImport/brandImporter.js
Normal file
91
src/services/companyImport/brandImporter.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// MARK: - Imports
|
||||
// #region Imports
|
||||
import fs from 'fs';
|
||||
import { computeSkeleHash } from './imageHasher.js';
|
||||
import { createLogger } from '../../util/logger.js';
|
||||
// #endregion
|
||||
|
||||
const log = createLogger('import');
|
||||
|
||||
// MARK: - Brand Importer
|
||||
// #region Brand Importer
|
||||
|
||||
/**
|
||||
* Imports brand manufacturers and attaches brand logos.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.store - Store singleton
|
||||
* @param {number} params.googleAuthId
|
||||
* @param {Map<string, Array<{ relPath: string, fullPath: string }>>} params.brandMap
|
||||
* @param {Array<object>} params.brandImageTasks
|
||||
* @param {object} params.progressTracker
|
||||
* @returns {Promise<{ brandNameToId: Map<string, number>, createdBrandCount: number, brandPicCount: number }>}
|
||||
*/
|
||||
export async function importBrands({
|
||||
store,
|
||||
googleAuthId,
|
||||
brandMap,
|
||||
brandImageTasks,
|
||||
progressTracker,
|
||||
}) {
|
||||
const brandNameToId = new Map();
|
||||
let createdBrandCount = 0;
|
||||
let brandPicCount = 0;
|
||||
|
||||
const { brands: existingBrandRows } = await store.brand.list(googleAuthId, { offset: 0, limit: 100 });
|
||||
const existingBrandNames = new Set(
|
||||
existingBrandRows.map((b) => (b.name || '').trim().toLowerCase()).filter(Boolean)
|
||||
);
|
||||
|
||||
const brandTaskMap = new Map();
|
||||
for (const task of brandImageTasks) {
|
||||
brandTaskMap.set(task.brandName.toLowerCase(), task);
|
||||
}
|
||||
|
||||
for (const [brandName] of brandMap.entries()) {
|
||||
const lowerName = brandName.toLowerCase();
|
||||
const isNew = !existingBrandNames.has(lowerName);
|
||||
|
||||
try {
|
||||
const brand = await store.brand.upsert(googleAuthId, { name: brandName });
|
||||
brandNameToId.set(lowerName, brand.id);
|
||||
if (isNew) {
|
||||
createdBrandCount++;
|
||||
existingBrandNames.add(lowerName);
|
||||
}
|
||||
|
||||
const imgTask = brandTaskMap.get(lowerName);
|
||||
if (imgTask) {
|
||||
if (!brand.pics || brand.pics.length === 0) {
|
||||
try {
|
||||
const imgBuf = fs.readFileSync(imgTask.fullPath);
|
||||
await store.brand.addImage(googleAuthId, brand.id, imgBuf);
|
||||
brandPicCount++;
|
||||
|
||||
const skele = await computeSkeleHash(imgBuf);
|
||||
progressTracker.emitSkeleHash(skele?.base62 || null);
|
||||
} catch (err) {
|
||||
log.warn(`Brand image error for "${brandName}":`, err.message);
|
||||
}
|
||||
}
|
||||
progressTracker.counts.imagesdone++;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Failed to upsert brand "${brandName}":`, err.message);
|
||||
}
|
||||
|
||||
progressTracker.emitProgress();
|
||||
}
|
||||
|
||||
progressTracker.flushSkeleHashes();
|
||||
progressTracker.emitProgress(true);
|
||||
log.info(`Brands resolved: ${brandNameToId.size}, created: ${createdBrandCount}, images: ${brandPicCount}`);
|
||||
|
||||
return {
|
||||
brandNameToId,
|
||||
createdBrandCount,
|
||||
brandPicCount,
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
170
src/services/companyImport/catalogPreprocessor.js
Normal file
170
src/services/companyImport/catalogPreprocessor.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// MARK: - Imports
|
||||
// #region Imports
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { cleanValue, parseCsvRecords } from './csvParser.js';
|
||||
// #endregion
|
||||
|
||||
// MARK: - Category & Article Filtering
|
||||
// #region Category & Article Filtering
|
||||
|
||||
/**
|
||||
* Filters out trash/papierkorb categories and their descendants,
|
||||
* as well as articles that exclusively belong to excluded categories.
|
||||
*
|
||||
* @param {Array<Record<string, string>>} catRecords
|
||||
* @param {Array<Record<string, string>>} artRecords
|
||||
* @returns {{ includedCategories: Array<Record<string, string>>, includedArticles: Array<Record<string, string>>, excludedJtlIds: Set<string> }}
|
||||
*/
|
||||
export function filterCatalogRecords(catRecords, artRecords) {
|
||||
const excludedJtlIds = new Set(['72']);
|
||||
|
||||
for (const c of catRecords) {
|
||||
let name = (c.cName || '').trim();
|
||||
if (!name) {
|
||||
try {
|
||||
const tr = JSON.parse(c.translations || '[]');
|
||||
name = tr[0]?.cName || '';
|
||||
} catch {}
|
||||
}
|
||||
if (name.toLowerCase().includes('papierkorb') || name.toLowerCase().includes('trash')) {
|
||||
excludedJtlIds.add(String(c.kKategorie));
|
||||
}
|
||||
}
|
||||
|
||||
let addedExcluded = true;
|
||||
while (addedExcluded) {
|
||||
addedExcluded = false;
|
||||
for (const c of catRecords) {
|
||||
const id = String(c.kKategorie);
|
||||
const parent = String(c.kOberKategorie);
|
||||
if (!excludedJtlIds.has(id) && excludedJtlIds.has(parent)) {
|
||||
excludedJtlIds.add(id);
|
||||
addedExcluded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const includedCategories = catRecords.filter((c) => !excludedJtlIds.has(String(c.kKategorie)));
|
||||
|
||||
const includedArticles = artRecords.filter((a) => {
|
||||
const catStr = String(a.categories || '').trim();
|
||||
if (!catStr) return true;
|
||||
let catIds = [];
|
||||
if (catStr.startsWith('[') || catStr.startsWith('{')) {
|
||||
try {
|
||||
const raw = JSON.parse(catStr);
|
||||
catIds = (Array.isArray(raw) ? raw : [raw]).map((it) => String(it.kKategorie || it));
|
||||
} catch {
|
||||
catIds = catStr.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
} else {
|
||||
catIds = catStr.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
if (catIds.length > 0 && catIds.every((cid) => excludedJtlIds.has(cid))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return { includedCategories, includedArticles, excludedJtlIds };
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// MARK: - Task Collection & Manifests
|
||||
// #region Task Collection & Manifests
|
||||
|
||||
/**
|
||||
* Scans directories for existing category, brand, and article images,
|
||||
* and loads receipts / manifest records if available.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.importDir
|
||||
* @param {Array<Record<string, string>>} params.includedCategories
|
||||
* @param {Array<Record<string, string>>} params.includedArticles
|
||||
* @returns {object} Discovered image tasks, brand map, and manifest records
|
||||
*/
|
||||
export function prepareImportTasks({ importDir, includedCategories, includedArticles }) {
|
||||
// Category images
|
||||
const categoryImageTasks = [];
|
||||
for (const c of includedCategories) {
|
||||
const picRel = (c.pictureFiles || '').trim();
|
||||
if (picRel) {
|
||||
for (const rel of picRel.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean)) {
|
||||
const full = path.resolve(importDir, rel);
|
||||
if (fs.existsSync(full)) {
|
||||
categoryImageTasks.push({ catJtlId: String(c.kKategorie), relPath: rel, fullPath: full });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Brand images (unique manufacturers, 1 primary logo)
|
||||
const brandMap = new Map(); // brandName -> array of { relPath, fullPath }
|
||||
for (const a of includedArticles) {
|
||||
const brand = cleanValue(a.Hersteller);
|
||||
if (!brand) continue;
|
||||
if (!brandMap.has(brand)) {
|
||||
brandMap.set(brand, []);
|
||||
const rawPics = String(a.manufacturerPictureFiles || '').trim();
|
||||
if (rawPics) {
|
||||
for (const rel of rawPics.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean)) {
|
||||
const full = path.resolve(importDir, rel);
|
||||
if (fs.existsSync(full)) {
|
||||
brandMap.get(brand).push({ relPath: rel, fullPath: full });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const brandImageTasks = [];
|
||||
for (const [brandName, pics] of brandMap.entries()) {
|
||||
if (pics.length > 0) {
|
||||
brandImageTasks.push({ brandName, ...pics[0] });
|
||||
}
|
||||
}
|
||||
|
||||
// Article images
|
||||
const articleImageTasks = [];
|
||||
for (const a of includedArticles) {
|
||||
const sku = cleanValue(a.cArtNr);
|
||||
if (!sku) continue;
|
||||
const rawPics = String(a.pictureFiles || '').trim();
|
||||
if (rawPics) {
|
||||
for (const rel of rawPics.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean)) {
|
||||
const full = path.resolve(importDir, rel);
|
||||
if (fs.existsSync(full)) {
|
||||
articleImageTasks.push({ sku, relPath: rel, fullPath: full });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalImagesCount = categoryImageTasks.length + brandImageTasks.length + articleImageTasks.length;
|
||||
|
||||
// Receipts / Manifests
|
||||
const manCsvPath = path.join(importDir, 'manifests.csv');
|
||||
const itemsCsvPath = path.join(importDir, 'manifestitems.csv');
|
||||
let manRecords = [];
|
||||
let itemRecords = [];
|
||||
if (fs.existsSync(manCsvPath) && fs.existsSync(itemsCsvPath)) {
|
||||
manRecords = parseCsvRecords(fs.readFileSync(manCsvPath, 'utf8'));
|
||||
itemRecords = parseCsvRecords(fs.readFileSync(itemsCsvPath, 'utf8'));
|
||||
}
|
||||
const totalReceiptsCount = manRecords.length;
|
||||
|
||||
return {
|
||||
categoryImageTasks,
|
||||
brandMap,
|
||||
brandImageTasks,
|
||||
articleImageTasks,
|
||||
totalImagesCount,
|
||||
manRecords,
|
||||
itemRecords,
|
||||
totalReceiptsCount,
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
148
src/services/companyImport/categoryImporter.js
Normal file
148
src/services/companyImport/categoryImporter.js
Normal file
@@ -0,0 +1,148 @@
|
||||
// MARK: - Imports
|
||||
// #region Imports
|
||||
import fs from 'fs';
|
||||
import { cleanValue } from './csvParser.js';
|
||||
import { computeSkeleHash } from './imageHasher.js';
|
||||
import { createLogger } from '../../util/logger.js';
|
||||
// #endregion
|
||||
|
||||
const log = createLogger('import');
|
||||
|
||||
// MARK: - Category Importer
|
||||
// #region Category Importer
|
||||
|
||||
/**
|
||||
* Imports category tree and attaches category images.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.store - Store singleton
|
||||
* @param {number} params.googleAuthId
|
||||
* @param {Array<Record<string, string>>} params.includedCategories
|
||||
* @param {Array<object>} params.categoryImageTasks
|
||||
* @param {object} params.progressTracker
|
||||
* @returns {Promise<{ jtlCatIdToDashCatId: Map<string, number>, hauptDashId: number, createdCatCount: number, catPicCount: number }>}
|
||||
*/
|
||||
export async function importCategories({
|
||||
store,
|
||||
googleAuthId,
|
||||
includedCategories,
|
||||
categoryImageTasks,
|
||||
progressTracker,
|
||||
}) {
|
||||
const treeResult = await store.category.getTreeForCompany(googleAuthId);
|
||||
const existingTree = treeResult.tree || [];
|
||||
const rootCategory = existingTree.find((c) => c.parentId == null) || existingTree[0];
|
||||
if (!rootCategory) {
|
||||
throw new Error('Company root category not found');
|
||||
}
|
||||
const rootCategoryId = rootCategory.id;
|
||||
|
||||
const existingMap = new Map();
|
||||
for (const c of existingTree) {
|
||||
const key = `${c.parentId ?? 'root'}:${String(c.name || '').trim().toLowerCase()}`;
|
||||
existingMap.set(key, c.id);
|
||||
}
|
||||
|
||||
const jtlCatIdToDashCatId = new Map();
|
||||
jtlCatIdToDashCatId.set('0', rootCategoryId);
|
||||
|
||||
// Group category images by JTL category ID
|
||||
const catImagesByJtlId = new Map();
|
||||
for (const task of categoryImageTasks) {
|
||||
if (!catImagesByJtlId.has(task.catJtlId)) {
|
||||
catImagesByJtlId.set(task.catJtlId, []);
|
||||
}
|
||||
catImagesByJtlId.get(task.catJtlId).push(task);
|
||||
}
|
||||
|
||||
const pendingCats = [...includedCategories];
|
||||
let createdCatCount = 0;
|
||||
let catPicCount = 0;
|
||||
let safety = 0;
|
||||
|
||||
while (pendingCats.length > 0 && safety < 10) {
|
||||
safety++;
|
||||
const remaining = [];
|
||||
for (const c of pendingCats) {
|
||||
const jtlId = String(c.kKategorie);
|
||||
const jtlParent = String(c.kOberKategorie || '0');
|
||||
|
||||
let name = cleanValue(c.cName);
|
||||
if (!name) {
|
||||
try {
|
||||
const tr = JSON.parse(c.translations || '[]');
|
||||
name = tr[0]?.cName || '';
|
||||
} catch {}
|
||||
}
|
||||
if (!name) name = `Kategorie ${jtlId}`;
|
||||
|
||||
let dashParentId;
|
||||
if (jtlParent === '0' || !jtlParent) {
|
||||
dashParentId = rootCategoryId;
|
||||
} else if (jtlCatIdToDashCatId.has(jtlParent)) {
|
||||
dashParentId = jtlCatIdToDashCatId.get(jtlParent);
|
||||
} else {
|
||||
remaining.push(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${dashParentId}:${name.toLowerCase()}`;
|
||||
let dashCatId = existingMap.get(key);
|
||||
|
||||
if (!dashCatId) {
|
||||
try {
|
||||
const created = await store.category.createUnderParent({
|
||||
googleAuthId,
|
||||
parentId: dashParentId,
|
||||
name,
|
||||
});
|
||||
dashCatId = created.id;
|
||||
existingMap.set(key, dashCatId);
|
||||
createdCatCount++;
|
||||
} catch (err) {
|
||||
log.warn(`Failed to create category "${name}":`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (dashCatId) {
|
||||
jtlCatIdToDashCatId.set(jtlId, dashCatId);
|
||||
|
||||
// Add category images
|
||||
const imgs = catImagesByJtlId.get(jtlId) || [];
|
||||
for (const imgTask of imgs) {
|
||||
try {
|
||||
const imgBuf = fs.readFileSync(imgTask.fullPath);
|
||||
await store.category.addImage(googleAuthId, dashCatId, imgBuf);
|
||||
catPicCount++;
|
||||
|
||||
const skele = await computeSkeleHash(imgBuf);
|
||||
progressTracker.emitSkeleHash(skele?.base62 || null);
|
||||
} catch (err) {
|
||||
log.warn(`Category image error (${imgTask.relPath}):`, err.message);
|
||||
}
|
||||
progressTracker.counts.imagesdone++;
|
||||
}
|
||||
}
|
||||
|
||||
progressTracker.counts.categoriesdone++;
|
||||
progressTracker.emitProgress();
|
||||
}
|
||||
pendingCats.length = 0;
|
||||
pendingCats.push(...remaining);
|
||||
}
|
||||
|
||||
progressTracker.flushSkeleHashes();
|
||||
progressTracker.emitProgress(true);
|
||||
log.info(`Categories resolved: ${jtlCatIdToDashCatId.size}/${includedCategories.length}, created: ${createdCatCount}, images: ${catPicCount}`);
|
||||
|
||||
const hauptDashId = jtlCatIdToDashCatId.get('1') || rootCategoryId;
|
||||
|
||||
return {
|
||||
jtlCatIdToDashCatId,
|
||||
hauptDashId,
|
||||
createdCatCount,
|
||||
catPicCount,
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
128
src/services/companyImport/csvParser.js
Normal file
128
src/services/companyImport/csvParser.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// MARK: - CSV Parsing & Value Sanitization
|
||||
// #region CSV Parsing & Value Sanitization
|
||||
|
||||
/**
|
||||
* Cleans formula prefixes (="..."), quotes, and whitespace from CSV values.
|
||||
*
|
||||
* @param {string|any} str
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cleanValue(str) {
|
||||
if (str == null) return '';
|
||||
let val = String(str).trim();
|
||||
if (val.startsWith('=')) {
|
||||
val = val.slice(1).trim();
|
||||
}
|
||||
if (val.startsWith('"') && val.endsWith('"')) {
|
||||
val = val.slice(1, -1).trim();
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses German decimal format numbers (e.g. "8,36" -> 8.36).
|
||||
*
|
||||
* @param {string|number} str
|
||||
* @param {number} [fallback=0]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function parseGermanFloat(str, fallback = 0) {
|
||||
const clean = cleanValue(str).replace(',', '.');
|
||||
const num = parseFloat(clean);
|
||||
return Number.isNaN(num) ? fallback : num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses CSV text into an array of objects keyed by header.
|
||||
* Supports auto-detecting semicolon (;) vs comma (,) delimiters,
|
||||
* quoted fields with embedded newlines/quotes, and formula prefixes.
|
||||
*
|
||||
* @param {string} text - Raw CSV text
|
||||
* @returns {Array<Record<string, string>>}
|
||||
*/
|
||||
export function parseCsvRecords(text) {
|
||||
const clean = text.replace(/^\uFEFF/, '');
|
||||
const firstLine = clean.split(/[\r\n]/)[0] || '';
|
||||
const semiCount = (firstLine.match(/;/g) || []).length;
|
||||
const commaCount = (firstLine.match(/,/g) || []).length;
|
||||
const delimiter = semiCount >= commaCount ? ';' : ',';
|
||||
|
||||
const rows = [];
|
||||
let field = '';
|
||||
let row = [];
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
const len = clean.length;
|
||||
|
||||
while (i < len) {
|
||||
const c = clean[i];
|
||||
if (inQuotes) {
|
||||
if (c === '"') {
|
||||
if (i + 1 < len && clean[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 2;
|
||||
continue;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
field += c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (c === '"') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
continue;
|
||||
} else if (c === delimiter) {
|
||||
row.push(cleanValue(field));
|
||||
field = '';
|
||||
i++;
|
||||
continue;
|
||||
} else if (c === '\r') {
|
||||
if (i + 1 < len && clean[i + 1] === '\n') {
|
||||
i++;
|
||||
}
|
||||
row.push(cleanValue(field));
|
||||
rows.push(row);
|
||||
field = '';
|
||||
row = [];
|
||||
i++;
|
||||
continue;
|
||||
} else if (c === '\n') {
|
||||
row.push(cleanValue(field));
|
||||
rows.push(row);
|
||||
field = '';
|
||||
row = [];
|
||||
i++;
|
||||
continue;
|
||||
} else {
|
||||
field += c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (field !== '' || row.length > 0) {
|
||||
row.push(cleanValue(field));
|
||||
rows.push(row);
|
||||
}
|
||||
if (rows.length === 0) return [];
|
||||
const header = rows[0].map((h) => cleanValue(h));
|
||||
const records = [];
|
||||
for (let r = 1; r < rows.length; r++) {
|
||||
const curr = rows[r];
|
||||
if (curr.length === 1 && curr[0] === '') continue;
|
||||
const obj = {};
|
||||
for (let col = 0; col < header.length; col++) {
|
||||
obj[header[col]] = curr[col] ?? '';
|
||||
}
|
||||
records.push(obj);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
41
src/services/companyImport/imageHasher.js
Normal file
41
src/services/companyImport/imageHasher.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// MARK: - Imports
|
||||
// #region Imports
|
||||
import sharp from 'sharp';
|
||||
import { encodeRGBA } from 'skelehash-wasm';
|
||||
import { createLogger } from '../../util/logger.js';
|
||||
// #endregion
|
||||
|
||||
const log = createLogger('import');
|
||||
|
||||
// MARK: - SkeleHash Computation
|
||||
// #region SkeleHash Computation
|
||||
|
||||
/**
|
||||
* Computes SkeleHash base62 hash for an image buffer.
|
||||
*
|
||||
* @param {Buffer} buffer
|
||||
* @returns {Promise<{base62: string, width: number, height: number, hashLength: number}|null>}
|
||||
*/
|
||||
export async function computeSkeleHash(buffer) {
|
||||
try {
|
||||
const { data, info } = await sharp(buffer)
|
||||
.rotate()
|
||||
.resize({ width: 200, height: 200, fit: 'inside' })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
const result = await encodeRGBA(info.width, info.height, data);
|
||||
return {
|
||||
base62: result.base62,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
hashLength: result.hash?.length ?? 0,
|
||||
};
|
||||
} catch (err) {
|
||||
log.warn('Failed to compute skelehash for image:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion
|
||||
165
src/services/companyImport/orderImporter.js
Normal file
165
src/services/companyImport/orderImporter.js
Normal file
@@ -0,0 +1,165 @@
|
||||
// MARK: - Imports
|
||||
// #region Imports
|
||||
import { cleanValue, parseGermanFloat } from './csvParser.js';
|
||||
import { createLogger } from '../../util/logger.js';
|
||||
// #endregion
|
||||
|
||||
const log = createLogger('import');
|
||||
|
||||
// MARK: - Order / Manifest Importer
|
||||
// #region Order / Manifest Importer
|
||||
|
||||
/**
|
||||
* Imports order manifests and line items, resolving customer entities.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.store - Store singleton
|
||||
* @param {number} params.googleAuthId
|
||||
* @param {Array<Record<string, string>>} params.manRecords
|
||||
* @param {Array<Record<string, string>>} params.itemRecords
|
||||
* @param {object} params.progressTracker
|
||||
* @returns {Promise<{ ordersImported: number, ordersSkipped: number }>}
|
||||
*/
|
||||
export async function importOrders({
|
||||
store,
|
||||
googleAuthId,
|
||||
manRecords,
|
||||
itemRecords,
|
||||
progressTracker,
|
||||
}) {
|
||||
let ordersImported = 0;
|
||||
let ordersSkipped = 0;
|
||||
|
||||
if (manRecords.length === 0 || itemRecords.length === 0) {
|
||||
return { ordersImported, ordersSkipped };
|
||||
}
|
||||
|
||||
const itemsByAuftrag = new Map();
|
||||
for (const it of itemRecords) {
|
||||
const kAuftrag = String(it.kAuftrag || '').trim();
|
||||
if (!kAuftrag) continue;
|
||||
if (!itemsByAuftrag.has(kAuftrag)) {
|
||||
itemsByAuftrag.set(kAuftrag, []);
|
||||
}
|
||||
itemsByAuftrag.get(kAuftrag).push(it);
|
||||
}
|
||||
|
||||
const existingOrdersResult = await store.order.list(googleAuthId);
|
||||
const existingOrderList = Array.isArray(existingOrdersResult)
|
||||
? existingOrdersResult
|
||||
: (existingOrdersResult?.orders || []);
|
||||
const existingOrderNumbers = new Set(
|
||||
existingOrderList.map((o) => (o.number || '').trim()).filter(Boolean)
|
||||
);
|
||||
|
||||
let defaultCustomerGroupId = null;
|
||||
try {
|
||||
const groups = await store.customergroup.list(googleAuthId);
|
||||
defaultCustomerGroupId = groups[0]?.id || null;
|
||||
} catch {}
|
||||
|
||||
const entityMap = new Map();
|
||||
try {
|
||||
const entities = await store.entity.list(googleAuthId);
|
||||
for (const ent of entities) {
|
||||
if (ent.name) {
|
||||
entityMap.set(ent.name.trim().toLowerCase(), ent.id);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const resolveEntityId = async (name, firstName) => {
|
||||
const cleanName = (name || '').trim();
|
||||
if (!cleanName || !defaultCustomerGroupId) return null;
|
||||
const key = cleanName.toLowerCase();
|
||||
if (entityMap.has(key)) return entityMap.get(key);
|
||||
|
||||
try {
|
||||
const ent = await store.entity.create(googleAuthId, {
|
||||
customergroupId: defaultCustomerGroupId,
|
||||
name: cleanName,
|
||||
firstName: (firstName || '').trim() || undefined,
|
||||
});
|
||||
entityMap.set(key, ent.id);
|
||||
return ent.id;
|
||||
} catch (err) {
|
||||
log.warn(`Could not create entity "${cleanName}":`, err.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const m of manRecords) {
|
||||
const orderNumber = cleanValue(m.cAuftragsNr);
|
||||
if (!orderNumber || existingOrderNumbers.has(orderNumber)) {
|
||||
ordersSkipped++;
|
||||
progressTracker.counts.receiptsdone++;
|
||||
progressTracker.emitProgress();
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawItems = itemsByAuftrag.get(String(m.kAuftrag || '').trim()) || [];
|
||||
if (rawItems.length === 0) {
|
||||
ordersSkipped++;
|
||||
progressTracker.counts.receiptsdone++;
|
||||
progressTracker.emitProgress();
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = rawItems.map((it) => {
|
||||
const sku = cleanValue(it.cArtNr) || null;
|
||||
const rawName = cleanValue(it.cName || it.cNameStandard);
|
||||
const name = rawName || (sku ? `Artikel ${sku}` : 'Artikel');
|
||||
const amount = parseGermanFloat(it.fAnzahl, 1);
|
||||
const net = parseGermanFloat(it.fVKNetto, 0);
|
||||
const taxrate = parseGermanFloat(it.fMwSt, 19);
|
||||
const rebate = parseGermanFloat(it.fRabatt, 0);
|
||||
const comment = cleanValue(it.cHinweis) || null;
|
||||
|
||||
return {
|
||||
sku,
|
||||
name,
|
||||
amount,
|
||||
net,
|
||||
taxrate,
|
||||
rebate,
|
||||
comment,
|
||||
};
|
||||
});
|
||||
|
||||
const custName = cleanValue(m.cName) || 'Laufkunde';
|
||||
const custFirstName = cleanValue(m.cVorname);
|
||||
const entityId = await resolveEntityId(custName, custFirstName);
|
||||
|
||||
const dateStr = cleanValue(m.dErstellt);
|
||||
const parsedDate = dateStr ? new Date(dateStr) : new Date();
|
||||
const validDate = !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : new Date().toISOString();
|
||||
|
||||
try {
|
||||
await store.order.create(googleAuthId, {
|
||||
number: orderNumber,
|
||||
externalNumber: cleanValue(m.cExterneAuftragsnummer) || null,
|
||||
date: validDate,
|
||||
createdAt: validDate,
|
||||
entityId: entityId || undefined,
|
||||
lines,
|
||||
});
|
||||
existingOrderNumbers.add(orderNumber);
|
||||
ordersImported++;
|
||||
} catch (err) {
|
||||
log.warn(`Failed to create order ${orderNumber}:`, err.message);
|
||||
}
|
||||
|
||||
progressTracker.counts.receiptsdone++;
|
||||
progressTracker.emitProgress();
|
||||
}
|
||||
|
||||
progressTracker.emitProgress(true);
|
||||
log.info(`Orders complete: ${ordersImported} created, ${ordersSkipped} skipped`);
|
||||
|
||||
return {
|
||||
ordersImported,
|
||||
ordersSkipped,
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
74
src/services/companyImport/progressTracker.js
Normal file
74
src/services/companyImport/progressTracker.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// MARK: - Progress & Image Stream Tracking
|
||||
// #region Progress & Image Stream Tracking
|
||||
|
||||
/**
|
||||
* Creates an event tracking controller for throttled progress updates and batched skelehashes.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.initialCounts - Initial totals and completed counts
|
||||
* @param {function} [params.onProgress] - Throttled callback for progress counts
|
||||
* @param {function} [params.onImages] - Batched callback for emitted skelehash base62 strings
|
||||
* @param {number} [params.progressThrottleMs=200]
|
||||
* @param {number} [params.imageBatchMs=50]
|
||||
* @returns {object} Tracker with mutateable counts and emission methods
|
||||
*/
|
||||
export function createProgressTracker({
|
||||
initialCounts,
|
||||
onProgress = () => {},
|
||||
onImages = () => {},
|
||||
progressThrottleMs = 200,
|
||||
imageBatchMs = 50,
|
||||
}) {
|
||||
const counts = {
|
||||
categoriestotal: 0,
|
||||
categoriesdone: 0,
|
||||
articlestotal: 0,
|
||||
articlesdone: 0,
|
||||
imagestotal: 0,
|
||||
imagesdone: 0,
|
||||
receiptstotal: 0,
|
||||
receiptsdone: 0,
|
||||
...initialCounts,
|
||||
};
|
||||
|
||||
let lastProgressEmit = 0;
|
||||
const emitProgress = (force = false) => {
|
||||
const now = Date.now();
|
||||
if (force || now - lastProgressEmit >= progressThrottleMs) {
|
||||
lastProgressEmit = now;
|
||||
onProgress({ ...counts });
|
||||
}
|
||||
};
|
||||
|
||||
let skeleBuffer = [];
|
||||
let lastSkeleEmit = Date.now();
|
||||
|
||||
const emitSkeleHash = (base62, force = false) => {
|
||||
if (base62) skeleBuffer.push(base62);
|
||||
const now = Date.now();
|
||||
if (skeleBuffer.length > 0 && (force || now - lastSkeleEmit >= imageBatchMs)) {
|
||||
const batch = skeleBuffer;
|
||||
skeleBuffer = [];
|
||||
lastSkeleEmit = now;
|
||||
onImages(batch);
|
||||
}
|
||||
};
|
||||
|
||||
const flushSkeleHashes = () => {
|
||||
if (skeleBuffer.length > 0) {
|
||||
const batch = skeleBuffer;
|
||||
skeleBuffer = [];
|
||||
lastSkeleEmit = Date.now();
|
||||
onImages(batch);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
counts,
|
||||
emitProgress,
|
||||
emitSkeleHash,
|
||||
flushSkeleHashes,
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
53
src/services/companyImport/variantHelpers.js
Normal file
53
src/services/companyImport/variantHelpers.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// MARK: - Variant Attribute & Value Helpers
|
||||
// #region Variant Attribute & Value Helpers
|
||||
|
||||
/**
|
||||
* Normalizes common variant attribute names and typos.
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeAttributeName(name) {
|
||||
const clean = (name || '').trim();
|
||||
if (!clean) return 'Variante';
|
||||
const lower = clean.toLowerCase();
|
||||
if (lower === 'menge' || lower === 'mange' || lower === 'meneg') return 'Menge';
|
||||
if (lower === 'farbe') return 'Farbe';
|
||||
if (lower === 'größe' || lower === 'grosse') return 'Größe';
|
||||
if (lower === 'sorte') return 'Sorte';
|
||||
if (lower === 'geschmack') return 'Geschmack';
|
||||
if (lower === 'typ') return 'Typ';
|
||||
if (lower === 'stück' || lower === 'stueck') return 'Stück';
|
||||
if (lower === 'ausführung' || lower === 'ausfuehrung') return 'Ausführung';
|
||||
if (lower === 'motiv') return 'Motiv';
|
||||
return clean.charAt(0).toUpperCase() + clean.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback variant value extraction if child has no explicit attribute value.
|
||||
*
|
||||
* @param {string} parentName
|
||||
* @param {string} childName
|
||||
* @param {string} childSku
|
||||
* @param {string} parentSku
|
||||
* @returns {string}
|
||||
*/
|
||||
export function extractVariantValue(parentName, childName, childSku, parentSku) {
|
||||
let pName = (parentName || '').replace(/[©®™]/g, '').trim();
|
||||
let cName = (childName || '').replace(/[©®™]/g, '').trim();
|
||||
|
||||
if (pName && cName) {
|
||||
if (cName.toLowerCase().startsWith(pName.toLowerCase())) {
|
||||
let val = cName.slice(pName.length).trim().replace(/^[-–—:/,\s]+/, '').trim();
|
||||
if (val) return val;
|
||||
}
|
||||
}
|
||||
if (parentSku && childSku && childSku.startsWith(parentSku)) {
|
||||
let suffix = childSku.slice(parentSku.length).trim().replace(/^[-_]+/, '').trim();
|
||||
if (suffix) return suffix;
|
||||
}
|
||||
if (cName && cName !== pName) return cName;
|
||||
return childSku || 'Standard';
|
||||
}
|
||||
|
||||
// #endregion
|
||||
129
src/services/companyImport/variantImporter.js
Normal file
129
src/services/companyImport/variantImporter.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// MARK: - Imports
|
||||
// #region Imports
|
||||
import { cleanValue } from './csvParser.js';
|
||||
import { normalizeAttributeName } from './variantHelpers.js';
|
||||
import { createLogger } from '../../util/logger.js';
|
||||
// #endregion
|
||||
|
||||
const log = createLogger('import');
|
||||
|
||||
// MARK: - Variant Importer
|
||||
// #region Variant Importer
|
||||
|
||||
/**
|
||||
* Resolves attribute definitions and links variant child articles to parents.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.store - Store singleton
|
||||
* @param {number} params.googleAuthId
|
||||
* @param {Array<Record<string, string>>} params.includedArticles
|
||||
* @param {Map<string, string>} params.jtlArtIdToSku
|
||||
* @returns {Promise<{ variantsLinked: number, parentsCount: number }>}
|
||||
*/
|
||||
export async function importVariants({
|
||||
store,
|
||||
googleAuthId,
|
||||
includedArticles,
|
||||
jtlArtIdToSku,
|
||||
}) {
|
||||
const attributeMap = new Map();
|
||||
try {
|
||||
const attrList = await store.attributename.list(googleAuthId);
|
||||
for (const attr of attrList) {
|
||||
if (attr.name) {
|
||||
attributeMap.set(attr.name.trim().toLowerCase(), attr.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Could not list attributes:', err.message);
|
||||
}
|
||||
|
||||
const resolveAttributeId = async (rawName) => {
|
||||
const normalized = normalizeAttributeName(rawName);
|
||||
const key = normalized.toLowerCase();
|
||||
if (attributeMap.has(key)) {
|
||||
return attributeMap.get(key);
|
||||
}
|
||||
try {
|
||||
const created = await store.attributename.create(googleAuthId, {
|
||||
name: normalized,
|
||||
exclusive: true,
|
||||
});
|
||||
attributeMap.set(key, created.id);
|
||||
return created.id;
|
||||
} catch (err) {
|
||||
log.warn(`Could not create attribute "${normalized}":`, err.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Group children by parent SKU
|
||||
const parentChildrenMap = new Map();
|
||||
for (const child of includedArticles) {
|
||||
const parentJtlId = String(child.kVaterArtikel || '').trim();
|
||||
if (!parentJtlId || parentJtlId === '0') continue;
|
||||
|
||||
const parentSku = jtlArtIdToSku.get(parentJtlId);
|
||||
const childSku = cleanValue(child.cArtNr);
|
||||
if (!parentSku || !childSku || parentSku === childSku) continue;
|
||||
|
||||
if (!parentChildrenMap.has(parentSku)) {
|
||||
const parentRow = includedArticles.find((a) => String(a.kArtikel) === parentJtlId) || null;
|
||||
parentChildrenMap.set(parentSku, { parentRow, children: [] });
|
||||
}
|
||||
parentChildrenMap.get(parentSku).children.push(child);
|
||||
}
|
||||
|
||||
let variantsLinked = 0;
|
||||
for (const [parentSku, { parentRow, children }] of parentChildrenMap.entries()) {
|
||||
const rawAttrName = parentRow ? cleanValue(parentRow.kEigenschaftKombi) : '';
|
||||
const targetAttrId = await resolveAttributeId(rawAttrName || 'Variante');
|
||||
|
||||
if (targetAttrId) {
|
||||
try {
|
||||
await store.article.toggleParent(googleAuthId, parentSku, {
|
||||
isParent: true,
|
||||
attributeNameId: targetAttrId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(`Could not toggle parent for ${parentSku}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const usedValues = new Set();
|
||||
for (const child of children) {
|
||||
const childSku = cleanValue(child.cArtNr);
|
||||
let val = cleanValue(child.kEigenschaftKombi) || 'Standard';
|
||||
|
||||
if (usedValues.has(val.toLowerCase())) {
|
||||
val = `${val} (${childSku})`;
|
||||
}
|
||||
usedValues.add(val.toLowerCase());
|
||||
|
||||
try {
|
||||
if (targetAttrId) {
|
||||
await store.article.attachChild(googleAuthId, parentSku, {
|
||||
sku: childSku,
|
||||
value: val,
|
||||
reparent: true,
|
||||
});
|
||||
variantsLinked++;
|
||||
} else {
|
||||
await store.article.setParentBySku(googleAuthId, childSku, parentSku);
|
||||
variantsLinked++;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Failed to attach child ${childSku} to parent ${parentSku}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Linked ${variantsLinked} variants across ${parentChildrenMap.size} parents`);
|
||||
|
||||
return {
|
||||
variantsLinked,
|
||||
parentsCount: parentChildrenMap.size,
|
||||
};
|
||||
}
|
||||
|
||||
// #endregion
|
||||
Reference in New Issue
Block a user