480 lines
16 KiB
JavaScript
480 lines
16 KiB
JavaScript
/**
|
||
* @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<string>} 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<ArticleIndexRecord>} 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<object>}>} views Loaded view data.
|
||
* @returns {Array<ArticleIndexRecord>} 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<ArticleIndexRecord>} 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<Array<ArticleIndexRecord>>} 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<Array<ArticleIndexRecord>>} Derived records.
|
||
*/
|
||
async reload() {
|
||
return actions.ensureLoaded();
|
||
},
|
||
|
||
/**
|
||
* Replaces the cached list without re-deriving it.
|
||
*
|
||
* @param {Array<ArticleIndexRecord>} items Records to cache.
|
||
* @returns {Array<ArticleIndexRecord>} 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<object|null>} 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<ArticleIndexState>}
|
||
*/
|
||
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<ArticleIndexRecord>|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<object>} [stockData.warehouses] Warehouse array.
|
||
* @param {Array<object>} [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<ArticleIndexRecord>} Current records.
|
||
*/
|
||
export function selectArticleIndexItems(state) {
|
||
return state.items;
|
||
}
|
||
|
||
export default articleIndexAutomat;
|