import { string } from 'alga-js' import fetchHelper from './fetchHelper' /** * Product lists WITHOUT `$expand=M_Storage`. * * Why: in idempiere-rest a detail expand (`$expand=M_Storage` on m_product) runs * `ExpandParser.getChildPOs → DefaultQueryConverter.convertStatement` once per * parent record, and that method is `synchronized` on a singleton. A list of * 3.3k products therefore becomes ~6.6k serialized queries under ONE global * lock; under CPU pressure a single call exceeds nginx's 60 s and, once * retried, queues every other API request behind it (2026-09-10 outage: * 178/200 Jetty threads blocked on that monitor). * * Instead: fetch the products plainly, fetch the m_storage rows in a handful * of paged list calls (7.9k rows today → 4 calls), and join in Node. Records * get the same `M_Storage: [...]` array shape the expand produced, so the * consumers (qty-on-hand pages, ag-grid, priority recommendations, the * all-products list) don't change. */ const STORAGE_PAGE = 2000 const IDS_PER_CHUNK = 100 // Storage rows are only cached for the duration of a page load burst: several // endpoints of the same screen (overview + by-locator) ask for the same set. const STORAGE_CACHE_TTL_MS = 30_000 type StorageMap = Map const storageCache = new Map() const storageInflight = new Map>() function cacheKey(event: any): string { // Visibility of m_storage rows depends on the iDempiere client/role/org of // the session, never on the user — cache per that triple. const c = getCookie(event, 'logship_client_id') || '' const r = getCookie(event, 'logship_role_id') || '' const o = getCookie(event, 'logship_organization_id') || '' return `${c}:${r}:${o}` } async function fetchStoragePages(event: any, token: any, filter: string | null): Promise { const rows: any[] = [] let skip = 0 for (;;) { const f = filter ? `&$filter=${string.urlEncode(filter)}` : '' const res: any = await fetchHelper( event, `models/m_storage?$orderby=${string.urlEncode('M_Storage_ID asc')}${f}&$top=${STORAGE_PAGE}&$skip=${skip}`, 'GET', token, null ) const page: any[] = Array.isArray(res?.records) ? res.records : [] rows.push(...page) if (page.length < STORAGE_PAGE) break skip += STORAGE_PAGE if (skip > 200_000) break // paranoia cap } return rows } /** All m_storage rows visible to this session, cached + single-flight. */ export async function fetchAllStorageRows(event: any, token: any): Promise { const key = cacheKey(event) const hit = storageCache.get(key) if (hit && Date.now() - hit.at < STORAGE_CACHE_TTL_MS) return hit.rows const running = storageInflight.get(key) if (running) return running const p = fetchStoragePages(event, token, null) .then((rows) => { storageCache.set(key, { at: Date.now(), rows }) return rows }) .finally(() => storageInflight.delete(key)) storageInflight.set(key, p) return p } /** m_storage rows for a specific (small) set of products — one call per 100 ids. */ export async function fetchStorageRowsForProducts(event: any, token: any, productIds: number[]): Promise { const ids = [...new Set(productIds.filter((n) => Number.isFinite(n) && n > 0))] const rows: any[] = [] for (let i = 0; i < ids.length; i += IDS_PER_CHUNK) { const chunk = ids.slice(i, i + IDS_PER_CHUNK) rows.push(...await fetchStoragePages(event, token, `M_Product_ID in (${chunk.join(',')})`)) } return rows } export function groupStorageByProduct(rows: any[]): StorageMap { const map: StorageMap = new Map() for (const row of rows) { const pid = Number(row?.M_Product_ID?.id ?? row?.M_Product_ID) if (!Number.isFinite(pid)) continue const list = map.get(pid) if (list) list.push(row) else map.set(pid, [row]) } return map } /** Sets `M_Storage` on every product record (same shape as the old expand). */ export function attachStorage(records: any[], storage: StorageMap): any[] { return records.map((p: any) => ({ ...p, M_Storage: storage.get(Number(p?.id)) ?? [] })) } /** * Drop-in replacement for `models/m_product?...&$expand=M_Storage[,other]...`. * Pass the product query WITHOUT the M_Storage expand; the helper picks the * cheaper storage strategy (by ids for a page, all rows for a full list). */ export async function fetchProductsWithStorage(event: any, token: any, productUrl: string): Promise { const res: any = await fetchHelper(event, productUrl, 'GET', token, null) const records: any[] = Array.isArray(res?.records) ? res.records : [] if (records.length === 0) return res const ids = records.map((p: any) => Number(p?.id)).filter((n) => Number.isFinite(n) && n > 0) const rows = ids.length <= IDS_PER_CHUNK * 3 ? await fetchStorageRowsForProducts(event, token, ids) : await fetchAllStorageRows(event, token) return { ...res, records: attachStorage(records, groupStorageByProduct(rows)) } }