This commit is contained in:
seb
2026-10-08 02:53:32 +02:00
parent cd396363bd
commit 73461c6e6f
6 changed files with 751 additions and 2 deletions

View File

@@ -0,0 +1,472 @@
import fs from 'fs';
import path from 'path';
import { getStore } from '../db/store.js';
/**
* Parses RFC 4180 CSV text into an array of objects keyed by header.
*
* @param {string} text - Raw CSV text
* @returns {Array<Record<string, string>>}
*/
export function parseCsvRecords(text) {
const clean = text.replace(/^\uFEFF/, '');
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 === ',') {
row.push(field);
field = '';
i++;
continue;
} else if (c === '\r') {
if (i + 1 < len && clean[i + 1] === '\n') {
i++;
}
row.push(field);
rows.push(row);
field = '';
row = [];
i++;
continue;
} else if (c === '\n') {
row.push(field);
rows.push(row);
field = '';
row = [];
i++;
continue;
} else {
field += c;
i++;
continue;
}
}
}
if (field !== '' || row.length > 0) {
row.push(field);
rows.push(row);
}
if (rows.length === 0) return [];
const header = rows[0].map((h) => h.trim());
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;
}
/**
* Executes full catalog import from preserved files in importDir.
*
* @param {object} opts
* @param {number} opts.googleAuthId
* @param {number} opts.companyId
* @param {string} opts.importDir
* @param {function} [opts.onProgress] - (phase, current, total, item) => void
* @returns {Promise<object>} Summary outcome
*/
export async function executeCompanyImport({
googleAuthId,
companyId,
importDir,
onProgress = () => {},
}) {
const store = await getStore();
const catCsvPath = path.join(importDir, 'categories.csv');
const artCsvPath = path.join(importDir, 'articles.csv');
if (!fs.existsSync(catCsvPath) || !fs.existsSync(artCsvPath)) {
throw new Error('Import files missing: categories.csv and articles.csv are required');
}
const catRaw = fs.readFileSync(catCsvPath, 'utf8');
const catRecords = parseCsvRecords(catRaw);
const artRaw = fs.readFileSync(artCsvPath, 'utf8');
const artRecords = parseCsvRecords(artRaw);
console.log(`[Import] Read ${catRecords.length} category rows and ${artRecords.length} article rows`);
// ─── Phase 1: Category Mapping & Creation ────────────────────────────────
// 1) Find existing category tree & company root category
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;
// Helper map of existing categories by parentId and lowercase name
const existingMap = new Map();
for (const c of existingTree) {
const key = `${c.parentId ?? 'root'}:${String(c.name || '').trim().toLowerCase()}`;
existingMap.set(key, c.id);
}
// Identify excluded categories (Papierkorb / Trash and its descendants)
const excludedJtlIds = new Set(['72']);
for (const c of catRecords) {
let 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));
}
}
// Also exclude any children of excluded categories
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;
}
}
}
console.log(`[Import] Excluded categories (trash/papierkorb):`, Array.from(excludedJtlIds));
// Filter included categories
const includedCategories = catRecords.filter((c) => !excludedJtlIds.has(String(c.kKategorie)));
// Sort categories so parents are created before children
// JTL kOberKategorie === '0' are top-level
const jtlCatIdToDashCatId = new Map();
const pendingCats = [...includedCategories];
let createdCatCount = 0;
let catPicCount = 0;
onProgress('categories', 0, includedCategories.length, 'Starting categories import');
let safety = 0;
while (pendingCats.length > 0 && safety < 10) {
safety++;
const remaining = [];
for (let i = 0; i < pendingCats.length; i++) {
const c = pendingCats[i];
const jtlId = String(c.kKategorie);
const jtlParent = String(c.kOberKategorie);
let 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 {
// Parent not created yet in this pass
remaining.push(c);
continue;
}
// Check if category already exists under dashParentId
const key = `${dashParentId}:${name.trim().toLowerCase()}`;
let dashCatId = existingMap.get(key);
if (!dashCatId) {
try {
const created = await store.category.createUnderParent({
googleAuthId,
parentId: dashParentId,
name: name.trim(),
});
dashCatId = created.id;
existingMap.set(key, dashCatId);
createdCatCount++;
} catch (err) {
console.warn(`[Import] Failed to create category "${name}":`, err.message);
}
}
if (dashCatId) {
jtlCatIdToDashCatId.set(jtlId, dashCatId);
// Check for category picture
const picRel = (c.pictureFiles || '').trim();
if (picRel) {
const picPath = path.join(importDir, picRel);
if (fs.existsSync(picPath)) {
try {
const imgBuf = fs.readFileSync(picPath);
await store.category.addImage(googleAuthId, dashCatId, imgBuf);
catPicCount++;
} catch (err) {
console.warn(`[Import] Category image error (${picRel}):`, err.message);
}
}
}
}
onProgress('categories', jtlCatIdToDashCatId.size, includedCategories.length, name);
}
pendingCats.length = 0;
pendingCats.push(...remaining);
}
console.log(`[Import] Categories resolved: ${jtlCatIdToDashCatId.size}/${includedCategories.length}, created new: ${createdCatCount}, images: ${catPicCount}`);
// Fallback: Haupt category ID for unmapped items
const hauptDashId = jtlCatIdToDashCatId.get('1') || rootCategoryId;
// ─── Phase 2: Article Import Pass 1 (Create or Update) ───────────────────
const includedArticles = artRecords.filter((a) => {
let cats = [];
try {
const rawCats = JSON.parse(a.categories || '[]');
cats = rawCats.map((it) => String(it.kKategorie));
} catch {}
// If all assigned categories are excluded, exclude article
if (cats.length > 0 && cats.every((cid) => excludedJtlIds.has(cid))) {
return false;
}
return true;
});
const totalArticles = includedArticles.length;
console.log(`[Import] Articles to process: ${totalArticles}`);
onProgress('articles', 0, totalArticles, 'Starting articles import');
let articlesImported = 0;
let articlesUpdated = 0;
const jtlArtIdToSku = new Map();
const articlesWithPictures = [];
for (let i = 0; i < totalArticles; i++) {
const a = includedArticles[i];
const jtlArtId = String(a.kArtikel);
const sku = String(a.cArtNr || '').trim();
if (!sku) continue;
jtlArtIdToSku.set(jtlArtId, sku);
let name = '';
try {
const desc = JSON.parse(a.descriptions || '[]');
name = desc[0]?.cName || '';
} catch {}
if (!name) name = `Artikel ${sku}`;
// Category mapping
const assignedDashCatIds = [];
try {
const rawCats = JSON.parse(a.categories || '[]');
for (const it of rawCats) {
const jtlCId = String(it.kKategorie);
if (jtlCatIdToDashCatId.has(jtlCId)) {
assignedDashCatIds.push(jtlCatIdToDashCatId.get(jtlCId));
}
}
} catch {}
const primaryCatId = assignedDashCatIds[0] || hauptDashId;
// Stock tracking
const trackStock = a.cLagerAktiv === 'Y' ? 1 : 0;
// Price & Tax
const netPrice = parseFloat(String(a.fVKNetto || '0').replace(',', '.')) || 0;
// kSteuerklasse '3' is reduced tax (7%), '1' is standard tax (19%)
const taxRate = a.kSteuerklasse === '3' ? 7 : 19;
// Barcode / EAN
const barcode = String(a.cBarcode || '').trim();
// Check if article already exists
let existingArticle = null;
try {
const res = await store.article.getBySku(googleAuthId, sku);
existingArticle = res.article;
} catch {
existingArticle = null;
}
try {
if (existingArticle) {
// Update existing article
await store.article.updateBySku(googleAuthId, sku, {
name: name.trim(),
track_stock: trackStock,
});
// Update prices
await store.article.setPricesBySku(googleAuthId, sku, [
{ net: netPrice, tax: taxRate },
]);
// Update category assignment
if (assignedDashCatIds.length > 0) {
await store.article.setCategoryIdsBySku(googleAuthId, sku, assignedDashCatIds);
}
// Update barcode if provided
if (barcode) {
const currentEans = existingArticle.eans || [];
if (!currentEans.includes(barcode)) {
await store.article.setEansBySku(googleAuthId, sku, [...currentEans, barcode]);
}
}
articlesUpdated++;
} else {
// Create new article
await store.article.createUnderParent({
googleAuthId,
parentId: primaryCatId,
name: name.trim(),
sku,
track_stock: trackStock,
});
// Set price
await store.article.setPricesBySku(googleAuthId, sku, [
{ net: netPrice, tax: taxRate },
]);
// Set additional categories if more than 1
if (assignedDashCatIds.length > 1) {
await store.article.setCategoryIdsBySku(googleAuthId, sku, assignedDashCatIds);
}
// Set barcode if present
if (barcode) {
await store.article.setEansBySku(googleAuthId, sku, [barcode]);
}
articlesImported++;
}
} catch (err) {
console.warn(`[Import] Failed to save article ${sku} ("${name}"):`, err.message);
}
// Keep note of picture references for image phase
const rawPics = (a.pictureFiles || '').trim();
if (rawPics) {
articlesWithPictures.push({ sku, pictureFiles: rawPics });
}
if (i % 25 === 0 || i === totalArticles - 1) {
onProgress('articles', i + 1, totalArticles, `${sku} - ${name}`);
}
}
console.log(`[Import] Article pass 1 complete. New: ${articlesImported}, Updated: ${articlesUpdated}`);
// ─── Phase 3: Article Import Pass 2 (Variant Links) ──────────────────────
const variantChildren = includedArticles.filter(
(a) => a.kVaterArtikel && a.kVaterArtikel !== '0' && a.kVaterArtikel.trim() !== ''
);
console.log(`[Import] Linking ${variantChildren.length} child variants to parents...`);
onProgress('variants', 0, variantChildren.length, 'Linking variants');
let variantsLinked = 0;
for (let i = 0; i < variantChildren.length; i++) {
const child = variantChildren[i];
const childSku = String(child.cArtNr || '').trim();
const parentJtlId = String(child.kVaterArtikel || '').trim();
const parentSku = jtlArtIdToSku.get(parentJtlId);
if (childSku && parentSku && childSku !== parentSku) {
try {
await store.article.setParentBySku(googleAuthId, childSku, parentSku);
variantsLinked++;
} catch (err) {
console.warn(`[Import] Failed to link child ${childSku} to parent ${parentSku}:`, err.message);
}
}
if (i % 20 === 0 || i === variantChildren.length - 1) {
onProgress('variants', i + 1, variantChildren.length, `${childSku} -> ${parentSku}`);
}
}
console.log(`[Import] Linked ${variantsLinked}/${variantChildren.length} variants`);
// ─── Phase 4: Article Pictures Import ────────────────────────────────────
console.log(`[Import] Importing pictures for ${articlesWithPictures.length} articles...`);
onProgress('images', 0, articlesWithPictures.length, 'Importing article pictures');
let totalImagesImported = 0;
for (let i = 0; i < articlesWithPictures.length; i++) {
const { sku, pictureFiles } = articlesWithPictures[i];
// Picture files may be separated by newline or semicolon
const lines = pictureFiles.replace(/\r/g, '\n').split('\n').map((l) => l.trim()).filter(Boolean);
for (const relPath of lines) {
const imgPath = path.join(importDir, relPath);
if (!fs.existsSync(imgPath)) continue;
try {
const imgBuf = fs.readFileSync(imgPath);
await store.article.addImage(googleAuthId, sku, imgBuf);
totalImagesImported++;
} catch (err) {
// If already attached or format issue, log and continue
console.warn(`[Import] Image add failed for SKU ${sku} (${relPath}):`, err.message);
}
}
if (i % 10 === 0 || i === articlesWithPictures.length - 1) {
onProgress('images', i + 1, articlesWithPictures.length, `SKU ${sku} (${lines.length} img)`);
}
}
console.log(`[Import] Completed article pictures: ${totalImagesImported} images processed`);
return {
ok: true,
message: 'Import erfolgreich abgeschlossen.',
stats: {
categories: jtlCatIdToDashCatId.size,
categoriesCreated: createdCatCount,
categoryPictures: catPicCount,
articlesTotal: totalArticles,
articlesImported,
articlesUpdated,
variantsLinked,
articlePictures: totalImagesImported,
},
};
}