/** * @file articleIndexAutomat.js * @description Company article search index used by the `/artikel` overview, * the manifest picker and the article-list pickers. * * The index is **derived**, not fetched: it is the de-duplicated union of the * articles of every loaded category view. `categoryAutomat.loadTree()` warms * those views right after `GET /api/category/tree` resolves, so the index is * ready without any additional request. * * `GET /api/articleIndex` (whole-company window) and the `/article/:sku/articlelist` * calls are no longer used by this store. * * Upstream Triggers: * - `categoryViewAutomat`: Subscribes to the keyed view store, so a view load, * reload, article creation or article removal re-derives the index. * - `categoryAutomat`: Re-derives once `treeStatus === 'succeeded'` and the * post-tree view preload (`viewsLoading`) settled; `reset()` on company switch. * - `authAutomat`: Resets to idle on `'anon'`. * - `sseAutomat`: `actions.reload()` on an `'articles'` event — now only * re-derives the index from the invalidated category views. * - `artikelAutomat`: `setArticleIndexName(sku, name)` on optimistic renames and * `actions.reset()` on mutation failure rollback. * * Downstream Triggers: * - `articleSearchAutomat`: Reads and searches records via `actions.ensureLoaded()`. * - `ArticleSearchDialog`, `ArticleViewLists`: `actions.ensureLoaded()` for a * flat article list. */ import { Automat } from 'react-automat'; import { article as articleApi } from '../api'; import authAutomat from './authAutomat'; import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat'; import sseAutomat from './sseAutomat'; import { categoryViews, collectLoadedCategoryViewData, loadAllCategoryViews, invalidateAllCategoryViews, updateArticleInCategoryViews, removeArticleBySku, } from './categoryViewAutomat'; /** * @typedef {Object} ArticleIndexRecord * @property {string|number} id Article id. * @property {string} sku Article SKU. * @property {string} name Article name. * @property {Array} eans Article EANs (not yet served per category — see below). * @property {string|null} categories Comma-joined category ids. * @property {boolean} isParent Whether the article has child variants. * * @typedef {Object} ArticleIndexState * @property {Array} items Derived index records. * @property {'idle'|'loading'|'ready'|'failed'} status Lifecycle state. * @property {Error|string|null} error Last error. */ /** @type {ArticleIndexState} */ const initialState = { items: [], status: 'idle', error: null, }; let articleIndexAutomat; /** In-flight `ensureLoaded()` promise so concurrent callers share one rebuild. */ let inflightEnsure = null; /** Pending coalesced rebuild, scheduled on the microtask queue. */ let rebuildScheduled = false; /** * Schedules a rebuild on the next microtask. * * Warming every category view fires one notification per view; coalescing keeps * the derivation at roughly O(articles) instead of O(categories × articles). * * @returns {void} */ function scheduleRebuild() { if (rebuildScheduled) return; rebuildScheduled = true; queueMicrotask(() => { rebuildScheduled = false; actions.rebuild(); }); } /** * Normalizes a category-view article row into an index record. * * NOTE: `eans` is not part of the `GET /api/category/:id/articles` payload, so the * field stays empty until that endpoint exposes EANs. EAN matching in * `articleSearchAutomat` is therefore not functional yet. * * @param {object} article Raw category-view article row. * @param {number|string|null} categoryId Category the article was loaded from. * @returns {ArticleIndexRecord} Index record. */ function toIndexRecord(article, categoryId) { const eans = article.eans ?? article.eanCodes ?? article.ean ?? null; const childCount = Number(article.childCount) || 0; const price = article.price ?? article.net ?? article.vknetto ?? (Array.isArray(article.prices) ? (article.prices.find((p) => p.minmultiplier === 1)?.net ?? article.prices[0]?.net) : null); const warehouses = Array.isArray(article.warehouses) ? article.warehouses : []; const trackStock = article.track_stock ?? article.trackStock; const stock = trackStock === false ? null : (article.stock != null && !Number.isNaN(Number(article.stock)) ? Number(article.stock) : (trackStock || warehouses.length > 0 ? warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0) : null)); const available = article.available != null && !Number.isNaN(Number(article.available)) ? Number(article.available) : stock; const categories = categoryId != null ? String(categoryId) : (Array.isArray(article.categoryIds) ? article.categoryIds.join(',') : (article.categories ?? null)); const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null; const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? null; const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? null; const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? null; const photos = article.photos ?? (fullHash ? [{ fullHash, thumbHash }] : []); return { ...article, id: article.id, sku: article.sku, name: article.name, price, net: article.net ?? price, stock, available, warehouses, articlewarehousesummary: Array.isArray(article.articlewarehousesummary) ? article.articlewarehousesummary : [], eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [], categories, isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)), childCount, fullHash, thumbHash, photos, smallThumbBinary, }; } /** * Merges the articles of all loaded category views into unique index records. * * An article assigned to several categories is emitted once, with every category * it was seen in accumulated in `categories`. * * @param {Array<{categoryId: number|string, articles: Array}>} views Loaded view data. * @returns {Array} Deduplicated index records. */ export function buildArticleIndex(views) { const bySku = new Map(); for (const view of views) { const categoryId = view?.categoryId; for (const article of view?.articles ?? []) { if (article?.sku == null) continue; const key = String(article.sku); const existing = bySku.get(key); if (!existing) { bySku.set(key, toIndexRecord(article, categoryId)); continue; } if (categoryId != null) { const ids = new Set(String(existing.categories ?? '').split(',').filter(Boolean)); ids.add(String(categoryId)); existing.categories = Array.from(ids).join(','); } const childCount = Number(article.childCount) || 0; if (childCount > (Number(existing.childCount) || 0)) { existing.childCount = childCount; } existing.isParent = Boolean( existing.isParent || (article.isParent ?? article.is_parent) || (childCount > 0) ); if (existing.price == null && (article.price != null || article.net != null)) { const p = article.price ?? article.net ?? article.vknetto; existing.price = p; existing.net = article.net ?? p; } if (existing.stock == null && (article.warehouses?.length > 0 || article.stock != null)) { const rec = toIndexRecord(article, categoryId); if (rec.stock != null) { existing.stock = rec.stock; existing.available = rec.available; existing.warehouses = rec.warehouses; existing.articlewarehousesummary = rec.articlewarehousesummary; } } } } return Array.from(bySku.values()); } const actions = { /** * Rebuilds the index from the currently loaded category views. * * Does not hit the network: the data comes from the view cache that * `categoryAutomat.loadTree()` populated. * * @returns {Array} Derived records. */ rebuild() { const items = buildArticleIndex(collectLoadedCategoryViewData()); articleIndexAutomat.setState({ items, status: 'ready', error: null }); return items; }, /** * Resolves once the index holds data, rebuilding it when necessary. * * Waits for the category tree and for the post-tree view preload, so the * returned list is complete rather than a partial early snapshot. * * @returns {Promise>} Derived records. */ async ensureLoaded() { if (inflightEnsure) return inflightEnsure; if (articleIndexAutomat.state.status === 'ready' && articleIndexAutomat.state.items.length > 0) { return articleIndexAutomat.state.items; } articleIndexAutomat.setState({ status: 'loading', error: null }); inflightEnsure = (async () => { await whenCategoryTreeReady(); // loadTree() already warms every view; this is the idempotent safety net // for a tree that finished loading before this automat subscribed. if (categoryAutomat.state.viewsLoading) { await loadAllCategoryViews(); } return actions.rebuild(); })() .catch((error) => { articleIndexAutomat.setState({ status: 'failed', error }); throw error; }) .finally(() => { inflightEnsure = null; }); return inflightEnsure; }, /** * Re-derives the index after the category views were invalidated by SSE. * * @returns {Promise>} Derived records. */ async reload() { return actions.ensureLoaded(); }, /** * Replaces the cached list without re-deriving it. * * @param {Array} items Records to cache. * @returns {Array} Cached records. */ replace(items) { articleIndexAutomat.setState({ items, status: 'ready', error: null }); return items; }, /** * Refetches and updates a single article in the index and category views by SKU. * * @param {string|number} sku Article SKU. * @returns {Promise} Fresh article or null. */ async updateArticleBySku(sku) { const key = String(sku); try { const res = await articleApi.getBySku(key); const article = res?.article; if (article) { updateArticleIndexRecord(article); return article; } return null; } catch (err) { if (err?.status === 404 || err?.statusCode === 404) { removeArticleBySku(key); const nextItems = articleIndexAutomat.state.items.filter((r) => String(r.sku) !== key); articleIndexAutomat.setState({ items: nextItems }); } return null; } }, /** Reset to idle state (logout / company switch). */ reset() { inflightEnsure = null; rebuildScheduled = false; articleIndexAutomat.setState(initialState); }, }; /** * Derived company article search index. * * @type {Automat} */ articleIndexAutomat = new Automat(initialState, actions, { name: 'articleIndex' }); export { articleIndexAutomat }; // Re-derive whenever a category view loads, reloads or is patched. categoryViews.subscribe(() => { scheduleRebuild(); }); // Re-derive once the tree finished loading and its view preload settled. categoryAutomat.subscribe((state) => { if (state.treeStatus === 'succeeded' && state.viewsLoading === false) { scheduleRebuild(); } }); if (categoryAutomat.state.treeStatus === 'succeeded' && categoryAutomat.state.viewsLoading === false) { scheduleRebuild(); } authAutomat.subscribe((auth) => { if (auth.status === 'anon') actions.reset(); }); // Upstream Trigger: sseAutomat if (sseAutomat?.actions?.onArticle) { sseAutomat.actions.onArticle(async (sku) => { if (articleIndexAutomat.state.status === 'ready') { await actions.updateArticleBySku(sku); } }); } if (sseAutomat?.actions?.on) { sseAutomat.actions.on('articles', () => { invalidateAllCategoryViews(); void actions.reload().catch(() => {}); }); sseAutomat.actions.on('warehouse', () => { invalidateAllCategoryViews(); void actions.reload().catch(() => {}); }); } /** * Updates a single article record in the derived index and loaded category views. * * @param {object} article Article detail or row. * @returns {Array|undefined} Updated items array. */ export function updateArticleIndexRecord(article) { if (!article || article.sku == null) return; const key = String(article.sku); updateArticleInCategoryViews(article); const items = articleIndexAutomat.state.items; if (!items || !items.length) return; const existingIndex = items.findIndex((r) => String(r.sku) === key); const categoryIds = Array.isArray(article.categoryIds) ? article.categoryIds.map(String) : []; const primaryCatId = categoryIds[0] ?? null; const record = toIndexRecord(article, primaryCatId); if (categoryIds.length > 0) { record.categories = categoryIds.join(','); } let nextItems; if (existingIndex >= 0) { const existing = items[existingIndex]; const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null; const hasPhotosProp = 'photos' in article; const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? (hasPhotosProp ? null : existing.fullHash); const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? (hasPhotosProp ? null : existing.thumbHash); const photos = article.photos ?? (hasPhotosProp ? [] : (existing.photos ?? [])); const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? (hasPhotosProp ? null : existing.smallThumbBinary); nextItems = [...items]; nextItems[existingIndex] = { ...existing, ...record, categories: record.categories ?? existing.categories, brand: article.brand ?? existing.brand, brandName: article.brand?.name ?? article.brandName ?? existing.brandName, fullHash, thumbHash, photos, smallThumbBinary, }; } else { nextItems = [...items, record]; } articleIndexAutomat.setState({ items: nextItems }); return nextItems; } /** * Patches a single record's name in the derived index (optimistic update). * * @param {string|number} sku Article SKU. * @param {string} name New article name. * @returns {void} */ export function setArticleIndexName(sku, name) { const items = articleIndexAutomat.state.items; if (!items.length) return; articleIndexAutomat.actions.replace( items.map((row) => String(row.sku) === String(sku) ? { ...row, name } : row, ), ); } /** * Patches a single record's stock in the derived index (e.g. after detail load or stock adjustment). * * @param {string|number} sku Article SKU. * @param {object} stockData Stock updates. * @param {number|null} [stockData.stock] Total stock. * @param {number|null} [stockData.available] Available stock. * @param {Array} [stockData.warehouses] Warehouse array. * @param {Array} [stockData.articlewarehousesummary] Summary array. * @returns {void} */ export function setArticleIndexStock(sku, { stock, available, warehouses, articlewarehousesummary } = {}) { const items = articleIndexAutomat.state.items; if (!items.length) return; articleIndexAutomat.actions.replace( items.map((row) => { if (String(row.sku) !== String(sku)) return row; const whs = warehouses ?? row.warehouses ?? []; const computedStock = stock !== undefined ? stock : (whs.length > 0 ? whs.reduce((sum, w) => sum + (Number(w.stock) || 0), 0) : row.stock); const computedAvailable = available !== undefined ? available : (computedStock !== undefined ? computedStock : row.available); return { ...row, stock: computedStock, available: computedAvailable, ...(warehouses ? { warehouses } : {}), ...(articlewarehousesummary ? { articlewarehousesummary } : {}), }; }), ); } /** * Selects the derived index records. * * @param {ArticleIndexState} state Automat state. * @returns {Array} Current records. */ export function selectArticleIndexItems(state) { return state.items; } export default articleIndexAutomat;