/** * @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 authAutomat from './authAutomat'; import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat'; import { categoryViews, collectLoadedCategoryViewData, loadAllCategoryViews, } 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 ?? null; return { ...article, id: article.id, sku: article.sku, name: article.name, price, net: article.net ?? price, eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [], categories: categoryId != null ? String(categoryId) : null, isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)), childCount, }; } /** * 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; } } } 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; }, /** 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(); } }); authAutomat.subscribe((auth) => { if (auth.status === 'anon') actions.reset(); }); /** * 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, ), ); } /** * Selects the derived index records. * * @param {ArticleIndexState} state Automat state. * @returns {Array} Current records. */ export function selectArticleIndexItems(state) { return state.items; } export default articleIndexAutomat;