/** * SIS (Sales Information System) — matches Amazon's bulk merchant-listings * report rows (see amazonSpApi.ts -> getMerchantListingsReport) to local * m_product records and joins in the internal qty-on-hand and sales price. * * Match order matters: `seller-sku` should always equal a LogShip * M_Product.Value or M_Product.SKU (that's what gets shipped as the SKU when * listing), so SKU is the primary key. m_product.asin is NOT always * populated in LogShip, so it is a fallback used only for rows the SKU tiers * miss — do not flip this order. */ import { string } from 'alga-js' import fetchHelper from './fetchHelper' import { fetchStorageRowsForProducts, groupStorageByProduct } from './productStorageHelper' import getReturnLocatorIds from './returnLocatorHelper' import type { MerchantListingRow } from './amazonSpApi' export interface SisRow { sellerSku: string asin: string amazonItemName: string amazonPrice: number | null amazonQty: number | null fulfillmentChannel: string status: string openDate: string | null productId: number | null productName: string productValue: string strapiProductDocumentId: string | null isBom: boolean internalPrice: number | null internalQtyOnHand: number | null returnQtyOnHand: number } const CHUNK_SIZE = 80 // Same combined-flag pattern used across the app (OrderPositionsTable.vue, // ProductSearchSelect.vue, SearchModal.vue, …) — a product counts as BOM // when either the native IsBOM flag or the JTL-synced isJtlBom flag is set. const isBomFlag = (product: any): boolean => { const v = product?.IsBOM ?? product?.isBOM const j = product?.isJtlBom ?? product?.IsJtlBom return v === true || v === 'Y' || j === true || j === 'Y' } const chunk = (arr: T[], size: number): T[][] => { const out: T[][] = [] for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)) return out } // Runs `fn` over every chunk with bounded concurrency instead of one at a // time — these are plain OData filter queries against m_product (no // $expand), so they don't hit the synchronized-lock path the "iDempiere REST // load rules" recipe warns about; a cap (rather than unbounded Promise.all) // just avoids opening dozens of simultaneous connections on a large catalog. const CHUNK_CONCURRENCY = 5 async function forEachChunk(chunks: T[][], fn: (c: T) => Promise): Promise { let next = 0 const workers = Array.from({ length: Math.min(CHUNK_CONCURRENCY, chunks.length) }, async () => { for (;;) { const i = next++ if (i >= chunks.length) return await fn(chunks[i]) } }) await Promise.all(workers) } // OData string literals are single-quoted; escape embedded quotes so a SKU // like O'Brien-123 can't break the filter. const escapeOData = (v: string): string => v.replace(/'/g, "''") /** * Resolves the current active m_pricelist_version for the app's default * price list (runtimeConfig.public.pricelistid, the same "101" default used * app-wide for new orders — see OrderQuickAddModal.vue). Cached briefly per * process since this rarely changes. */ const priceListVersionCache = new Map() const PRICE_LIST_VERSION_TTL_MS = 10 * 60 * 1000 export async function resolveActivePriceListVersionId(event: any, token: string, priceListId: string | number): Promise { const key = String(priceListId) const cached = priceListVersionCache.get(key) if (cached && Date.now() - cached.at < PRICE_LIST_VERSION_TTL_MS) return cached.id let id: number | null = null try { const filter = `M_PriceList_ID eq ${Number(priceListId)} and isActive eq true` const res: any = await fetchHelper( event, `models/m_pricelist_version?$filter=${string.urlEncode(filter)}&$orderby=${string.urlEncode('ValidFrom desc')}&$top=1`, 'GET', token, null ) id = res?.records?.[0]?.id ?? null } catch { id = null } priceListVersionCache.set(key, { id, at: Date.now() }) return id } /** Batch-resolves M_Product records matching a set of SKUs (Value or SKU field) or ASINs, scoped to one org. */ async function fetchMatchCandidates( event: any, token: string, orgId: number, skus: string[], asins: string[] ): Promise<{ bySku: Map; byAsin: Map }> { const bySku = new Map() const byAsin = new Map() await Promise.all([ forEachChunk(chunk(skus, CHUNK_SIZE), async (skuChunk) => { if (skuChunk.length === 0) return const orClause = skuChunk .map(s => `Value eq '${escapeOData(s)}' or SKU eq '${escapeOData(s)}'`) .join(' or ') const filter = `AD_Org_ID eq ${orgId} and (${orClause})` const res: any = await fetchHelper(event, `models/m_product?$filter=${string.urlEncode(filter)}&$top=500`, 'GET', token, null) for (const p of (res?.records || [])) { if (p.Value) bySku.set(String(p.Value).toLowerCase(), p) if (p.SKU) bySku.set(String(p.SKU).toLowerCase(), p) } }), forEachChunk(chunk(asins, CHUNK_SIZE), async (asinChunk) => { if (asinChunk.length === 0) return const orClause = asinChunk.map(a => `asin eq '${escapeOData(a)}'`).join(' or ') const filter = `AD_Org_ID eq ${orgId} and (${orClause})` const res: any = await fetchHelper(event, `models/m_product?$filter=${string.urlEncode(filter)}&$top=500`, 'GET', token, null) for (const p of (res?.records || [])) { if (p.asin) byAsin.set(String(p.asin).toLowerCase(), p) } }) ]) return { bySku, byAsin } } /** Batch-resolves m_productprice.priceStd for a set of product ids at one price-list version. */ async function fetchPricesForProducts( event: any, token: string, productIds: number[], priceListVersionId: number | null ): Promise> { const prices = new Map() if (!priceListVersionId || productIds.length === 0) return prices await forEachChunk(chunk(productIds, CHUNK_SIZE), async (idChunk) => { const filter = `M_PriceList_Version_ID eq ${priceListVersionId} and M_Product_ID in (${idChunk.join(',')})` const res: any = await fetchHelper(event, `models/m_productprice?$filter=${string.urlEncode(filter)}`, 'GET', token, null) for (const pp of (res?.records || [])) { const pid = Number(pp?.M_Product_ID?.id ?? pp?.M_Product_ID) const price = Number(pp?.priceStd ?? pp?.PriceStd) if (Number.isFinite(pid) && Number.isFinite(price)) prices.set(pid, price) } }) return prices } /** * Joins Amazon listing rows to local products. Rows with no local match are * kept (productId: null) rather than dropped — a listing live on Amazon with * nothing tracked internally is useful information on its own. */ export async function matchListingsToProducts( event: any, token: string, orgId: number, listings: MerchantListingRow[], priceListId: string | number ): Promise { const skus = [...new Set(listings.map(l => l.sellerSku).filter(Boolean))] const asins = [...new Set(listings.map(l => l.asin).filter(Boolean))] const [{ bySku, byAsin }, priceListVersionId, returnLocatorIds] = await Promise.all([ fetchMatchCandidates(event, token, orgId, skus, asins), resolveActivePriceListVersionId(event, token, priceListId), getReturnLocatorIds(event, token, orgId) ]) const resolved = listings.map(l => { const bySkuMatch = bySku.get(l.sellerSku.toLowerCase()) const product = bySkuMatch || (l.asin ? byAsin.get(l.asin.toLowerCase()) : null) || null return { listing: l, product } }) const matchedIds = [...new Set(resolved.filter(r => r.product).map(r => Number(r.product.id)))] const [storageRows, prices] = await Promise.all([ matchedIds.length ? fetchStorageRowsForProducts(event, token, matchedIds) : Promise.resolve([]), fetchPricesForProducts(event, token, matchedIds, priceListVersionId) ]) const storageByProduct = groupStorageByProduct(storageRows) const qtyOnHandFor = (productId: number) => { const rows = storageByProduct.get(productId) || [] let onHand = 0 let returnOnHand = 0 for (const s of rows) { const qty = Number(s?.QtyOnHand ?? 0) const locId = Number(s?.M_Locator_ID?.id ?? s?.M_Locator_ID) if (returnLocatorIds.has(locId)) returnOnHand += qty else onHand += qty } return { onHand, returnOnHand } } return resolved.map(({ listing, product }) => { const productId = product ? Number(product.id) : null const { onHand, returnOnHand } = productId ? qtyOnHandFor(productId) : { onHand: 0, returnOnHand: 0 } return { sellerSku: listing.sellerSku, asin: listing.asin, amazonItemName: listing.itemName, amazonPrice: listing.price, amazonQty: listing.quantity, fulfillmentChannel: listing.fulfillmentChannel, status: listing.status, openDate: listing.openDate, productId, productName: product?.Name || '', productValue: product?.Value || product?.SKU || '', strapiProductDocumentId: product?.Strapi_Product_documentId || null, isBom: product ? isBomFlag(product) : false, internalPrice: productId ? (prices.get(productId) ?? null) : null, internalQtyOnHand: productId ? onHand : null, returnQtyOnHand: returnOnHand } }) }