Files
dashfront/src/automats/artikelAutomat.js
2026-10-04 22:47:26 +02:00

330 lines
10 KiB
JavaScript

/**
* @file artikelAutomat.js
* @description In-memory reactive cache and mutator for full single-article
* detail payloads (`GET /api/article/:sku`), with inflight request deduplication.
*
* Upstream Triggers:
* - `sseAutomat`: Calls `reloadArticle(sku)` via `invalidateArticle(sku)` when an
* `article-<sku>` SSE mutation event arrives.
* - `authAutomat`: Subscribes to auth state changes — calls `actions.clear()` when
* status transitions to `'anon'` (logout).
* - UI / Routes: Calls `loadArticle(sku)` or mutations (`setName`, `setEans`,
* `setBrand`, `setCategories`, `setPrices`, `setWeight`, etc.).
*
* Downstream Triggers:
* - `articleIndexAutomat`: Calls `setArticleIndexName(sku, name)` on optimistic
* article renames, and `articleIndexAutomat.actions.reset()` on mutation failure rollback.
*/
import { Automat } from 'react-automat';
import { article as articleApi } from '../api';
import { requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat';
import articleIndexAutomat, { setArticleIndexName } from './articleIndexAutomat';
/**
* @typedef {Object} ArtikelState
* @property {Object<string, object>} bySku Cached article detail keyed by SKU.
* Missing keys mean "not loaded"; a value is the full `GET /api/article/{sku}`
* detail payload (prices, photos, eans, brand, unit, categoryIds, …).
*/
/** @type {ArtikelState} */
const initialState = {
bySku: {},
};
/**
* In-flight detail requests keyed by SKU. Concurrent consumers (view tiles,
* route guards, SSE refreshes) share a single `GET /api/article/{sku}`.
*
* @type {Map<string, Promise<object>>}
*/
const inflight = new Map();
/** @type {Automat<ArtikelState>} */
let artikelAutomat;
const actions = {
/**
* Loads an article into the shared cache, deduplicating concurrent callers.
* Resolves with the cached article when it is already present.
*
* @param {string} sku Article SKU.
* @returns {Promise<object>} Loaded article detail.
*/
loadArticle(sku) {
const key = String(sku ?? '').trim();
if (!key) {
return Promise.reject(new Error('artikelAutomat.loadArticle: SKU fehlt'));
}
const cached = artikelAutomat.state.bySku[key];
if (cached) return Promise.resolve(cached);
const pending = inflight.get(key);
if (pending) return pending;
const request = articleApi
.getBySku(key)
.then((res) => {
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
requireDefined(res.article.sku, 'article.sku');
return artikelAutomat.actions.setArticle(res.article);
})
.finally(() => {
inflight.delete(key);
});
inflight.set(key, request);
return request;
},
/**
* Silently re-fetches an article, keeping the cached copy until the new one
* arrives. Use for SSE invalidation and pull-to-refresh.
*
* @param {string} sku Article SKU.
* @returns {Promise<object>} Fresh article detail.
*/
async reloadArticle(sku) {
const key = String(sku ?? '').trim();
if (!key) throw new Error('artikelAutomat.reloadArticle: SKU fehlt');
const res = await articleApi.getBySku(key);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
requireDefined(res.article.sku, 'article.sku');
return artikelAutomat.actions.setArticle(res.article);
},
/**
* Stores an article detail payload in the cache. Call after any GET/PATCH/PUT
* response so every subscriber sees the same object.
*
* @param {object} article Article detail with at least a `sku`.
* @returns {object} The stored article.
*/
setArticle(article) {
requireObject(article, 'article');
requireDefined(article.sku, 'article.sku');
const key = String(article.sku);
artikelAutomat.setState({
bySku: { ...artikelAutomat.state.bySku, [key]: article },
});
return article;
},
/**
* Patches the cached article name without a round-trip (optimistic update).
*
* @param {string} sku Article SKU.
* @param {string} name New name.
* @returns {object|null} Updated article, or `null` when not cached.
*/
setArticleName(sku, name) {
const key = String(sku);
const current = artikelAutomat.state.bySku[key];
if (!current) return null;
return artikelAutomat.actions.setArticle({ ...current, name });
},
/**
* Drops an article from the cache (e.g. after deletion), forcing the next
* `loadArticle` to hit the backend.
*
* @param {string} sku Article SKU.
* @returns {void}
*/
clearArticle(sku) {
const key = String(sku);
if (!(key in artikelAutomat.state.bySku)) return;
const bySku = { ...artikelAutomat.state.bySku };
delete bySku[key];
artikelAutomat.setState({ bySku });
},
/**
* PATCH /api/article/:sku with optimistic name handling.
*
* A `name` in the body is applied to both caches immediately so the tile
* does not flicker, then rolled back from the server on failure.
*
* @param {string} sku Article SKU.
* @param {object} body Patch body (`{ name?, unitamount?, unitSi?, track_stock? }`).
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async update(sku, body) {
const key = String(sku);
const nameChange = body != null && Object.hasOwn(body, 'name');
if (nameChange) {
setArticleIndexName(key, body.name);
actions.setArticleName(key, body.name);
}
try {
const res = await articleApi.update(key, body);
actions.setArticle(res.article);
return res;
} catch (err) {
if (nameChange) {
// The optimistic patch was wrong: drop the index entry and refetch.
articleIndexAutomat.actions.reset();
if (hasArticle(key)) {
void actions.loadArticle(key).catch(() => {});
}
}
throw err;
}
},
/**
* PUT /api/article/:sku/eans → refreshes the cached article.
*
* @param {string} sku Article SKU.
* @param {Array<string>} eans Replacement EAN list.
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setEans(sku, eans) {
const res = await articleApi.setEansBySku(String(sku), eans);
actions.setArticle(res.article);
return res;
},
/**
* PUT /api/article/:sku/brand → refreshes the cached article.
*
* @param {string} sku Article SKU.
* @param {{ brandId: number|null }} body Brand payload.
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setBrand(sku, body) {
const res = await articleApi.setBrandIdBySku(String(sku), body?.brandId);
actions.setArticle(res.article);
return res;
},
/**
* PUT /api/article/:sku/categories → refreshes the cached article.
*
* @param {string} sku Article SKU.
* @param {Array<number>} categoryIds Replacement category ids.
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setCategories(sku, categoryIds) {
const res = await articleApi.setCategories(String(sku), categoryIds);
actions.setArticle(res.article);
return res;
},
/**
* PUT /api/article/:sku/prices → refreshes the cached article.
*
* @param {string} sku Article SKU.
* @param {Array<object>} prices Net price rows.
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setPrices(sku, prices) {
const res = await articleApi.setPrices(String(sku), prices);
actions.setArticle(res.article);
return res;
},
/**
* PUT /api/article/:sku/weight → refreshes the cached article.
*
* @param {string} sku Article SKU.
* @param {object} body Weight payload.
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setWeight(sku, body) {
const res = await articleApi.setWeight(String(sku), body);
actions.setArticle(res.article);
return res;
},
/**
* PUT /api/article/:sku/track_stock → refreshes the cached article.
*
* @param {string} sku Article SKU.
* @param {boolean|object} body Flag or full body.
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setTrackStock(sku, body) {
const res = await articleApi.setTrackStock(String(sku), body);
if (res?.article) actions.setArticle(res.article);
return res;
},
/**
* Forgets every cached article.
*
* @returns {void}
*/
clear() {
artikelAutomat.setState({ bySku: {} });
},
};
/**
* Shared cache for article detail payloads. State lives in module scope so
* navigating between articles (and remounting the view) reuses the cache
* instead of re-fetching.
*
* @type {Automat<ArtikelState>}
*/
artikelAutomat = new Automat(initialState, actions, { name: 'artikel' });
/**
* Reads a cached article synchronously.
*
* @param {string} sku Article SKU.
* @returns {object|null} Cached article detail, or `null`.
*/
export function getArticle(sku) {
if (sku == null) return null;
return artikelAutomat.state.bySku[String(sku)] ?? null;
}
/**
* Whether an article detail payload is currently cached.
*
* @param {string} sku Article SKU.
* @returns {boolean} `true` when cached.
*/
export function hasArticle(sku) {
return getArticle(sku) != null;
}
/**
* Builds a selector for one SKU, so a component can subscribe to just that
* article instead of every cache change.
*
* @param {string} sku Article SKU.
* @returns {(state: ArtikelState) => object|null} Selector function.
*/
export function createArticleSelector(sku) {
const key = sku == null ? null : String(sku);
return (state) => (key == null ? null : state.bySku[key] ?? null);
}
/**
* Shorthand for `artikelAutomat.select(createArticleSelector(sku))`.
*
* @param {string} sku Article SKU.
* @returns {import('react-automat').ScopedSlice<object|null>} Scoped slice.
*/
export function selectArticle(sku) {
return artikelAutomat.select(createArticleSelector(sku));
}
// Drop cached articles on logout. A company switch keeps them; the SSE hub
// reconnects and revalidates whatever is on screen.
authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
actions.clear();
}
});
export default artikelAutomat;