u
This commit is contained in:
472
src/services/companyImport.js
Normal file
472
src/services/companyImport.js
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user