import { string } from 'alga-js' /** * Per-org cache of locator id sets resolved by locator-type flags. The * locator-type/locator config is admin-static and changes very rarely, but these * helpers are called once PER PRODUCT on hot paths (e.g. /api/local/qty-on-hand, * which the FFN stock page now fires up to ~1000× per load). Without caching, * each call ran 2 extra iDempiere queries. Cached per Nitro process with a short * TTL (fail-soft; never caches an error). */ const LOCATOR_IDS_TTL_MS = 10 * 60 * 1000 const locatorIdsCache: Map; expires: number }> = new Map() /** * Resolves the ids of all active locators whose M_LocatorType matches the given * OData filter. Locators without a locator type never match. */ const getLocatorIdsByTypeFilter = async ( event: any, token: string, orgId: number | string | null, typeFilterRaw: string, cachePrefix: string ): Promise> => { const cacheKey = `${cachePrefix}:${orgId ?? 'all'}` const cached = locatorIdsCache.get(cacheKey) if (cached && cached.expires > Date.now()) return cached.ids const result = new Set() try { const typeFilter = string.urlEncode(typeFilterRaw) const ltRes: any = await event.context.fetch( `models/m_locatortype?$filter=${typeFilter}&$top=1000`, 'GET', token, null ) const typeIds = (ltRes?.records || []).map((t: any) => t?.id).filter((id: any) => id !== undefined && id !== null) if (typeIds.length === 0) { // Valid "no matching locators" answer — safe to cache. locatorIdsCache.set(cacheKey, { ids: result, expires: Date.now() + LOCATOR_IDS_TTL_MS }) return result } const typeFilterParts = typeIds.map((id: number) => `M_LocatorType_ID eq ${id}`).join(' OR ') let locFilter = `(${typeFilterParts}) AND IsActive eq true` if (orgId !== null && orgId !== undefined && orgId !== '') { locFilter += ` AND AD_Org_ID eq ${orgId}` } const locRes: any = await event.context.fetch( `models/m_locator?$filter=${string.urlEncode(locFilter)}&$top=10000`, 'GET', token, null ) for (const loc of (locRes?.records || [])) { if (loc?.id !== undefined && loc?.id !== null) result.add(loc.id) } } catch (err) { console.error(`[getLocatorIdsByTypeFilter:${cachePrefix}] Error:`, err) // Do NOT cache on error — return a (possibly partial) result for this call only. return result } locatorIdsCache.set(cacheKey, { ids: result, expires: Date.now() + LOCATOR_IDS_TTL_MS }) return result } /** * Locators whose M_LocatorType has all three IsAvailableFor* flags set to * false are considered "return" locators. Stock sitting on them should be * reported separately from the regular on-hand quantity. */ const getReturnLocatorIds = async ( event: any, token: string, orgId: number | string | null = null ): Promise> => { return getLocatorIdsByTypeFilter( event, token, orgId, 'IsAvailableForReplenishment eq false AND IsAvailableForReservation eq false AND IsAvailableForShipping eq false AND IsActive eq true', 'return' ) } /** * Locators whose M_LocatorType allows neither shipping nor reservation * (regardless of the replenishment flag). Receipts must never auto-preset * these as a destination — a superset of the return locators above. */ export const getNonReceivableLocatorIds = async ( event: any, token: string, orgId: number | string | null = null ): Promise> => { return getLocatorIdsByTypeFilter( event, token, orgId, 'IsAvailableForShipping eq false AND IsAvailableForReservation eq false AND IsActive eq true', 'nonreceivable' ) } export default getReturnLocatorIds