diff --git a/src/db/store.js b/src/db/store.js index 16dc2f6..a1b6c22 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -52,6 +52,8 @@ const STORE_NAMESPACES = [ 'payment', 'shipping', 'package', + 'articlelist', + 'optionslist', ]; /** diff --git a/src/services/companyImport.js b/src/services/companyImport.js index c955d94..b19c5c4 100644 --- a/src/services/companyImport.js +++ b/src/services/companyImport.js @@ -14,6 +14,7 @@ import { importCategories } from './companyImport/categoryImporter.js'; import { importBrands } from './companyImport/brandImporter.js'; import { importArticles } from './companyImport/articleImporter.js'; import { importVariants } from './companyImport/variantImporter.js'; +import { importPfand, extractPfandValue, formatPfandSku, formatPfandName } from './companyImport/pfandImporter.js'; import { importArticleImages } from './companyImport/articleImageImporter.js'; import { importOrders } from './companyImport/orderImporter.js'; // #endregion @@ -27,6 +28,10 @@ export { computeSkeleHash, normalizeAttributeName, extractVariantValue, + extractPfandValue, + formatPfandSku, + formatPfandName, + importPfand, }; // #endregion @@ -151,8 +156,19 @@ export async function executeCompanyImport({ }); // #endregion - // MARK: - Phase 5: Article Pictures - // #region Phase 5: Article Pictures + // MARK: - Phase 5: Pfand / Deposit Options + // #region Phase 5: Pfand + const pfandResult = await importPfand({ + store, + googleAuthId, + includedArticles, + hauptDashId: categoriesResult.hauptDashId, + parentSkus: variantsResult.parentSkus, + }); + // #endregion + + // MARK: - Phase 6: Article Pictures + // #region Phase 6: Article Pictures const imagesResult = await importArticleImages({ store, googleAuthId, @@ -161,8 +177,8 @@ export async function executeCompanyImport({ }); // #endregion - // MARK: - Phase 6: Orders / Receipts - // #region Phase 6: Orders / Receipts + // MARK: - Phase 7: Orders / Receipts + // #region Phase 7: Orders / Receipts const ordersResult = await importOrders({ store, googleAuthId, @@ -190,6 +206,9 @@ export async function executeCompanyImport({ articlesImported: articlesResult.articlesImported, articlesUpdated: articlesResult.articlesUpdated, variantsLinked: variantsResult.variantsLinked, + pfandArticlesCreated: pfandResult.pfandArticlesCreated, + pfandArticlesUpdated: pfandResult.pfandArticlesUpdated, + pfandOptionsLinked: pfandResult.pfandOptionsLinked, articlePictures: imagesResult.totalImagesImported, ordersTotal: totalReceiptsCount, ordersImported: ordersResult.ordersImported, diff --git a/src/services/companyImport/pfandImporter.js b/src/services/companyImport/pfandImporter.js new file mode 100644 index 0000000..c003c34 --- /dev/null +++ b/src/services/companyImport/pfandImporter.js @@ -0,0 +1,241 @@ +// MARK: - Imports +// #region Imports +import { cleanValue } from './csvParser.js'; +import { createLogger } from '../../util/logger.js'; +// #endregion + +const log = createLogger('import'); + +const PFAND_REGEX = /pfand\s*:\s*([0-9]+(?:[.,][0-9]+)?)/i; + +// MARK: - Helpers +// #region Helpers + +/** + * Parses the Pfand deposit value from an attributes string. + * E.g. "pfand:0.08" -> 0.08, "pfand:0,25" -> 0.25. + * + * @param {string} attrStr + * @returns {number|null} + */ +export function extractPfandValue(attrStr) { + if (!attrStr) return null; + const match = String(attrStr).match(PFAND_REGEX); + if (!match) return null; + const num = parseFloat(match[1].replace(',', '.')); + return Number.isFinite(num) && num > 0 ? num : null; +} + +/** + * Formats standard Pfand SKU: e.g. PFAND-0.08, PFAND-0.15, PFAND-0.25. + * + * @param {number} val + * @returns {string} + */ +export function formatPfandSku(val) { + return `PFAND-${Number(val).toFixed(2)}`; +} + +/** + * Formats standard Pfand article name: e.g. "Pfand 0,08 €", "Pfand 0,15 €", "Pfand 0,25 €". + * + * @param {number} val + * @returns {string} + */ +export function formatPfandName(val) { + return `Pfand ${Number(val).toFixed(2).replace('.', ',')} €`; +} + +// #endregion + +// MARK: - Pfand Importer +// #region Pfand Importer + +/** + * Imports Pfand deposit articles and links them as mandatory options to beverages/articles. + * + * @param {object} params + * @param {object} params.store - Store singleton + * @param {number} params.googleAuthId + * @param {Array>} params.includedArticles + * @param {number} params.hauptDashId + * @param {Set} [params.parentSkus] - Set of SKUs that are parent articles (which cannot have options) + * @returns {Promise<{ pfandArticlesCreated: number, pfandArticlesUpdated: number, pfandOptionsLinked: number }>} + */ +export async function importPfand({ + store, + googleAuthId, + includedArticles, + hauptDashId, + parentSkus = new Set(), +}) { + // 1. Identify parent articles and map their Pfand values so child variants can inherit + const parentPfandMap = new Map(); + for (const a of includedArticles) { + const sku = cleanValue(a.cArtNr); + const isParent = a.nIstVater === '1' || (sku && parentSkus.has(sku)); + if (isParent) { + const val = extractPfandValue(a.attributes); + if (val != null) { + parentPfandMap.set(String(a.kArtikel), val); + } + } + } + + // 2. Discover target articles (purchasable standalone or variant items) and their Pfand values + const articleTargets = []; + for (const a of includedArticles) { + const sku = cleanValue(a.cArtNr); + if (!sku) continue; + + const isParent = a.nIstVater === '1' || parentSkus.has(sku); + if (isParent) { + // Parent articles cannot have options in Dash + continue; + } + + let pfandVal = extractPfandValue(a.attributes); + if (pfandVal == null && a.kVaterArtikel && a.kVaterArtikel !== '0') { + if (parentPfandMap.has(String(a.kVaterArtikel))) { + pfandVal = parentPfandMap.get(String(a.kVaterArtikel)); + } + } + + if (pfandVal != null) { + articleTargets.push({ sku, pfandVal }); + } + } + + if (articleTargets.length === 0) { + log.info('No pfand attributes detected among articles.'); + return { + pfandArticlesCreated: 0, + pfandArticlesUpdated: 0, + pfandOptionsLinked: 0, + }; + } + + // 3. Find unique Pfand values + const uniqueValues = Array.from(new Set(articleTargets.map((t) => t.pfandVal))).sort((a, b) => a - b); + log.info(`Discovered ${articleTargets.length} articles with Pfand across ${uniqueValues.length} unique values: ${uniqueValues.join(', ')}`); + + // 4. Resolve or create "Pfand" subcategory under "Haupt" + let pfandCatId = null; + try { + const treeResult = await store.category.getTreeForCompany(googleAuthId); + const existingTree = treeResult.tree || []; + const existingPfandCat = existingTree.find( + (c) => c.parentId === hauptDashId && String(c.name || '').trim().toLowerCase() === 'pfand' + ); + + if (existingPfandCat) { + pfandCatId = existingPfandCat.id; + } else { + const created = await store.category.createUnderParent({ + googleAuthId, + parentId: hauptDashId, + name: 'Pfand', + }); + pfandCatId = created.id; + log.info(`Created "Pfand" category under Haupt (id: ${pfandCatId})`); + } + } catch (err) { + log.warn('Could not resolve or create Pfand category under Haupt, falling back to Haupt:', err.message); + pfandCatId = hauptDashId; + } + + // 5. Create or update Pfand articles + let pfandArticlesCreated = 0; + let pfandArticlesUpdated = 0; + + for (const val of uniqueValues) { + const pfandSku = formatPfandSku(val); + const pfandName = formatPfandName(val); + + let existingArticle = null; + try { + const res = await store.article.getBySku(googleAuthId, pfandSku); + existingArticle = res.article; + } catch { + existingArticle = null; + } + + try { + if (existingArticle) { + await store.article.updateBySku(googleAuthId, pfandSku, { + name: pfandName, + track_stock: 0, + }); + await store.article.setPricesBySku(googleAuthId, pfandSku, [ + { gross: val, tax: 19 }, + ]); + pfandArticlesUpdated++; + log.info(`Updated existing Pfand article: ${pfandSku} ("${pfandName}")`); + } else { + await store.article.createUnderParent({ + googleAuthId, + parentId: pfandCatId, + name: pfandName, + sku: pfandSku, + track_stock: 0, + }); + await store.article.setPricesBySku(googleAuthId, pfandSku, [ + { gross: val, tax: 19 }, + ]); + pfandArticlesCreated++; + log.info(`Created new Pfand article: ${pfandSku} ("${pfandName}")`); + } + } catch (err) { + log.warn(`Failed to create or update Pfand article ${pfandSku}:`, err.message); + } + } + + // 6. Link each target article to its Pfand article as a mandatory option + let pfandOptionsLinked = 0; + + for (const { sku, pfandVal } of articleTargets) { + const pfandSku = formatPfandSku(pfandVal); + + let existingOptions = []; + try { + existingOptions = (await store.article.getOptionslist(googleAuthId, sku)) || []; + } catch { + existingOptions = []; + } + + // Filter out any existing PFAND options to prevent duplicates, keep any other options + const otherOptions = existingOptions.filter((o) => !o.sku?.startsWith('PFAND-')); + const newOptions = [ + ...otherOptions.map((o, idx) => ({ + sku: o.sku, + articleId: o.articleId, + count: o.count || 1, + sort: idx, + mandated: Boolean(o.mandated), + })), + { + sku: pfandSku, + count: 1, + sort: otherOptions.length, + mandated: true, + }, + ]; + + try { + await store.article.setOptionslist(googleAuthId, sku, newOptions); + pfandOptionsLinked++; + } catch (err) { + log.warn(`Failed to link Pfand option ${pfandSku} to article ${sku}:`, err.message); + } + } + + log.info(`Pfand import complete: ${pfandArticlesCreated} created, ${pfandArticlesUpdated} updated, ${pfandOptionsLinked} linked as mandatory options.`); + + return { + pfandArticlesCreated, + pfandArticlesUpdated, + pfandOptionsLinked, + }; +} + +// #endregion diff --git a/src/services/companyImport/variantImporter.js b/src/services/companyImport/variantImporter.js index 041238e..cbd6fbc 100644 --- a/src/services/companyImport/variantImporter.js +++ b/src/services/companyImport/variantImporter.js @@ -123,6 +123,7 @@ export async function importVariants({ return { variantsLinked, parentsCount: parentChildrenMap.size, + parentSkus: new Set(parentChildrenMap.keys()), }; }