// 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 Setup & Linking Helpers // #region Pfand Setup & Linking Helpers /** * Pre-creates the Pfand category and Pfand articles upfront. * * @param {object} params * @param {object} params.store - Store singleton * @param {number} params.googleAuthId * @param {number} params.hauptDashId * @param {Array>} params.includedArticles * @returns {Promise<{ pfandArticlesCreated: number, pfandArticlesUpdated: number, pfandCatId: number|null, uniqueValues: number[] }>} */ export async function setupPfandArticles({ store, googleAuthId, hauptDashId, includedArticles, }) { // 1. Discover unique Pfand values from all included articles (checking attributes) const uniqueValuesSet = new Set(); for (const a of includedArticles) { const val = extractPfandValue(a.attributes); if (val != null) { uniqueValuesSet.add(val); } } if (uniqueValuesSet.size === 0) { log.info('No pfand attributes detected among articles.'); return { pfandArticlesCreated: 0, pfandArticlesUpdated: 0, pfandCatId: null, uniqueValues: [], }; } const uniqueValues = Array.from(uniqueValuesSet).sort((a, b) => a - b); log.info(`Discovered ${uniqueValues.length} unique Pfand values: ${uniqueValues.join(', ')}`); // 2. 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; } // 3. 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); } } return { pfandArticlesCreated, pfandArticlesUpdated, pfandCatId, uniqueValues, }; } /** * Links an article to its mandatory Pfand deposit option. * * @param {object} params * @param {object} params.store * @param {number} params.googleAuthId * @param {string} params.sku * @param {number} params.pfandVal * @returns {Promise} */ export async function linkArticlePfand({ store, googleAuthId, sku, pfandVal, }) { if (!sku || pfandVal == null) return false; const pfandSku = formatPfandSku(pfandVal); let existingOptions = []; try { existingOptions = (await store.article.getOptionslist(googleAuthId, sku)) || []; } catch { existingOptions = []; } 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); return true; } catch (err) { log.warn(`Failed to link Pfand option ${pfandSku} to article ${sku}:`, err.message); return false; } } // #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.sku ?? a.cArtNr); const isParent = a.is_parent === '1' || a.nIstVater === '1' || (sku && parentSkus.has(sku)); if (isParent) { const val = extractPfandValue(a.attributes); if (val != null) { parentPfandMap.set(String(a.id ?? 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.sku ?? a.cArtNr); if (!sku) continue; const isParent = a.is_parent === '1' || a.nIstVater === '1' || parentSkus.has(sku); if (isParent) { // Parent articles cannot have options in Dash continue; } let pfandVal = extractPfandValue(a.attributes); const parentId = cleanValue(a.parentid ?? a.kVaterArtikel); if (pfandVal == null && parentId && parentId !== '0') { if (parentPfandMap.has(parentId)) { pfandVal = parentPfandMap.get(parentId); } } 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. Setup Pfand category and Pfand articles const setupResult = await setupPfandArticles({ store, googleAuthId, hauptDashId, includedArticles, }); // 4. Link each target article to its Pfand article as a mandatory option let pfandOptionsLinked = 0; for (const { sku, pfandVal } of articleTargets) { const linked = await linkArticlePfand({ store, googleAuthId, sku, pfandVal, }); if (linked) pfandOptionsLinked++; } log.info(`Pfand import complete: ${setupResult.pfandArticlesCreated} created, ${setupResult.pfandArticlesUpdated} updated, ${pfandOptionsLinked} linked as mandatory options.`); return { pfandArticlesCreated: setupResult.pfandArticlesCreated, pfandArticlesUpdated: setupResult.pfandArticlesUpdated, pfandOptionsLinked, }; } // #endregion