import { string } from 'alga-js' import refreshTokenHelper from "../../utils/refreshTokenHelper" import forceLogoutHelper from "../../utils/forceLogoutHelper" import errorHandlingHelper from "../../utils/errorHandlingHelper" import getTokenHelper from "../../utils/getTokenHelper" import fetchHelper from "../../utils/fetchHelper" import getReturnLocatorIds from "../../utils/returnLocatorHelper" import { odataQuote } from "../../utils/odataHelper" const handleFunc = async (event: any, authToken: any = null) => { let data: any = {} const token = authToken ?? await getTokenHelper(event) const body = await readBody(event) const searchTerm = body?.search || '' const includeAllOrgs = body?.includeAllOrgs || false const filterOrgId = body?.organizationId || null // Opt-in BOM exclusion — set by callers (e.g. mobile receive) that must not scan // BOM articles. Off by default so other callers (returns, movement, …) are unaffected. const excludeBom = body?.excludeBom === true || body?.excludeBom === 'true' // Direct by-id lookup. Set by callers that already know the EXACT product the // user picked (e.g. mobile receive arriving from /mobile/product). Bypasses the // ambiguous code search + duplicate resolution so the exact clicked article is // returned — never a different product that happens to share a Value/SKU/UPC. const directProductId = body?.productId ? Number(body.productId) : null if (!searchTerm && !(directProductId && directProductId > 0)) { throw createError({ statusCode: 400, statusMessage: 'Search term or productId is required' }) } // Search by Value, SKU, UPC, or MPN. Match in tiers, exact first: raw term → // separators stripped ("PZN-15821889" → "PZN15821889") → numeric-core match // ("-15821889" or "PZN 15821889" finds stored "PZN15821889" / "PZN - 15821889": // the STORED side may carry separators or a prefix the scan lacks, which no // OData eq can normalize — only tier 3's JS post-filter can). // Quote-escaped for the OData string literals below (an apostrophe in a // scanned value would otherwise break every tier's filter). const lowerSearchTerm = odataQuote(searchTerm.toLowerCase()) const strippedTerm = lowerSearchTerm.replace(/[\s-]+/g, '') // Filter by organization if provided (include org 0 = system-wide products) const orgClause = (filterOrgId && !includeAllOrgs) ? ` AND (AD_Org_ID eq ${Number(filterOrgId)} OR AD_Org_ID eq 0)` : '' // When excluding BOMs: IsBOM is the standard iDempiere flag (always present → // filtered server-side); the custom isJtlBom column is filtered in JS (it may // not exist in every env, so it can't go in $filter). Applied PER TIER, so a // tier whose only hits are JTL-BOM articles counts as empty and the next // (looser) tier still runs — otherwise a JTL-BOM twin sharing the scanned code // shadows the real article and the lookup 404s although the article exists. const bomClause = excludeBom ? ' AND (IsBOM eq false)' : '' const dropJtlBom = (records: any[]) => !excludeBom ? (records || []) : (records || []).filter( (p: any) => !((p.isJtlBom ?? p.IsJtlBom) === true || (p.isJtlBom ?? p.IsJtlBom) === 'Y')) const eqFilter = (term: string) => `(tolower(Value) eq '${term}' OR tolower(SKU) eq '${term}' OR tolower(UPC) eq '${term}' OR tolower(mpn) eq '${term}' OR tolower(fnsku) eq '${term}') AND (isActive eq true)${bomClause}${orgClause}` // Tier 1: exact term — OR a direct by-id fetch when the caller already knows // the product. A by-id hit yields exactly one record, so the duplicate branch // and the lower search tiers below are skipped naturally (their guards require // an empty result set / a non-empty search term). let res: any = null if (directProductId && directProductId > 0) { const byId: any = await fetchHelper( event, `models/m_product/${directProductId}`, 'GET', token, null ) res = (byId && byId.id) ? { records: [byId] } : { records: [] } } else { const t1: any = await fetchHelper( event, `models/m_product?$filter=${string.urlEncode(eqFilter(lowerSearchTerm))}`, 'GET', token, null ) res = { ...(t1 || {}), records: dropJtlBom(t1?.records) } } // Tier 2: separators stripped if ((!res?.records || res.records.length === 0) && strippedTerm && strippedTerm !== lowerSearchTerm) { const t2: any = await fetchHelper( event, `models/m_product?$filter=${string.urlEncode(eqFilter(strippedTerm))}`, 'GET', token, null ) res = { ...(t2 || {}), records: dropJtlBom(t2?.records) } } // Tier 3: numeric-core barcode — the term is a number, optionally behind an // alphabetic prefix ("15821889", "-15821889", "PZN 15821889", "PZN07407383"). // contains() on the NUMBER finds stored codes even when THEY carry separators // or a prefix the eq tiers can't normalize ("PZN - 15821889"); the post-filter // then enforces the exact shape: stripped equality, or bare number on one side // ↔ alphabetic prefix + same number on the other. A prefixed term never // matches a DIFFERENT alphabetic prefix. const coreMatch = (!res?.records || res.records.length === 0) ? strippedTerm.match(/^([a-z]*)(\d{6,})$/) : null if (coreMatch) { const numCore = coreMatch[2] const containsFilter = `(contains(tolower(Value),'${numCore}') OR contains(tolower(SKU),'${numCore}') OR contains(tolower(UPC),'${numCore}') OR contains(tolower(mpn),'${numCore}') OR contains(tolower(fnsku),'${numCore}')) AND (isActive eq true)${bomClause}${orgClause}` const sres: any = await fetchHelper( event, `models/m_product?$filter=${string.urlEncode(containsFilter)}&$top=50`, 'GET', token, null ) const term = strippedTerm.toUpperCase() const n = numCore.toUpperCase() const hadPrefix = coreMatch[1].length > 0 const isTolerantMatch = (p: any) => [p.Value, p.SKU, p.UPC, p.mpn, p.fnsku].some((v: any) => { if (!v) return false const u = String(v).toUpperCase().replace(/[\s-]+/g, '') if (u === term || u === n) return true return !hadPrefix && u.endsWith(n) && /^[A-Z]+$/.test(u.slice(0, u.length - n.length)) }) res = { ...(sres || {}), records: dropJtlBom((sres?.records || []).filter(isTolerantMatch)) } } if (res?.records && res.records.length > 0) { // Multiple products found — return duplicate list for user to choose. // Per-duplicate storage fetches run in PARALLEL (they were sequential — // a common code shared by many products serialized N heavy calls). if (res.records.length > 1) { const duplicates = await Promise.all(res.records.map(async (p: any) => { // Fetch qtyonhand for each product (split into regular + return) let qtyonhand = 0 let returnQtyOnHand = 0 try { const storagesRes: any = await fetchHelper( event, `models/m_storage?$filter=${string.urlEncode('M_Product_ID eq ' + p.id)}&$expand=M_Locator_ID&$top=1000`, 'GET', token, null ) const storages = Array.isArray(storagesRes?.records) ? storagesRes.records : [] const dupOrgId = p.AD_Org_ID?.id ?? p.AD_Org_ID ?? null const dupReturnLocatorIds = await getReturnLocatorIds(event, token, dupOrgId) for (const s of storages) { const qty = Number(s?.QtyOnHand ?? 0) const locId = s?.M_Locator_ID?.id ?? s?.M_Locator_ID if (locId !== undefined && locId !== null && dupReturnLocatorIds.has(Number(locId))) { returnQtyOnHand += qty } else { qtyonhand += qty } } } catch (_e) { /* ignore */ } return { id: p.id, Name: p.Name, Value: p.Value, SKU: p.SKU, UPC: p.UPC, MPN: p.mpn || null, ASIN: p.asin || null, FNSKU: p.fnsku || null, isBOM: (p.IsBOM ?? p.isBOM) === true || (p.IsBOM ?? p.isBOM) === 'Y', isJtlBom: (p.isJtlBom ?? p.IsJtlBom) === true || (p.isJtlBom ?? p.IsJtlBom) === 'Y', AD_Org_ID: p.AD_Org_ID || null, qtyonhand: qtyonhand, returnQtyOnHand: returnQtyOnHand } })) data = { duplicates: duplicates, searchTerm: searchTerm } return data } const product = res.records[0] // Fetch storages for this product with locator and warehouse info expanded const storagesRes: any = await fetchHelper( event, `models/m_storage?$filter=${string.urlEncode('M_Product_ID eq ' + product.id)}&$expand=${string.urlEncode('M_Locator_ID,M_Warehouse')}&$top=1000`, 'GET', token, null ) const storages = Array.isArray(storagesRes?.records) ? storagesRes.records : [] const singleProductOrgId = product.AD_Org_ID?.id ?? product.AD_Org_ID ?? null const returnLocatorIds = await getReturnLocatorIds(event, token, singleProductOrgId) // Aggregate total qty on hand and per-locator quantities. Storages sitting // on locators whose type has all three IsAvailableFor* flags = false are // tracked separately as Return Qty On Hand. let qtyonhand = 0 let returnQtyOnHand = 0 const locatorMap: Record = {} let warehouseIdFromStorage = null for (const s of storages) { const qty = Number(s?.QtyOnHand ?? 0) if (!qty) continue const locObj = s?.M_Locator_ID || s?.m_locator_id || {} const locId = locObj?.id ?? String(locObj) ?? 'unknown' const isReturn = locId !== undefined && locId !== null && returnLocatorIds.has(Number(locId)) if (isReturn) { returnQtyOnHand += qty } else { qtyonhand += qty } const locX = locObj?.X const locY = locObj?.Y const locZ = locObj?.Z const locCode = (locX || locY || locZ) ? [locX, locY, locZ].filter(Boolean).join(' - ') : locObj?.Value || locObj?.identifier || locObj?.Name || locObj?.value || String(locId) // Extract warehouse ID from M_Warehouse array const warehouseArray = s?.M_Warehouse || s?.m_warehouse || [] const locWarehouseId = Array.isArray(warehouseArray) && warehouseArray.length > 0 ? warehouseArray[0]?.id : null // Extract organization ID from expanded locator const locOrgId = locObj?.AD_Org_ID?.id || locObj?.AD_Org_ID || null // Capture warehouse ID from first locator with inventory if (!warehouseIdFromStorage && locWarehouseId) { warehouseIdFromStorage = locWarehouseId } if (!locatorMap[locId]) { locatorMap[locId] = { id: locId, code: locCode, qtyOnHand: 0, warehouseId: locWarehouseId, orgId: locOrgId, x: locX, y: locY, z: locZ, isReturn } } locatorMap[locId].qtyOnHand += qty } const productOrgId = product.AD_Org_ID?.id || product.AD_Org_ID const warehouseId = body?.warehouseId let locators = Object.values(locatorMap) .filter(l => l.qtyOnHand > 0 && (includeAllOrgs || !l.orgId || l.orgId === productOrgId)) .sort((a, b) => b.qtyOnHand - a.qtyOnHand) // Fetch locators for the product's organization in the warehouse let availableLocators: any[] = [] let orgWarehouseIds: any[] = [] console.log('[product-lookup] warehouseId:', warehouseId, 'productOrgId:', productOrgId) if (warehouseId || productOrgId) { // Find warehouses that belong to the product's org // This ensures we only show locators from correctly owned warehouses let locatorFilter = 'IsActive eq true' if (productOrgId) { const whRes: any = await fetchHelper( event, `models/m_warehouse?$filter=${string.urlEncode('AD_Org_ID eq ' + productOrgId + ' AND IsActive eq true')}`, 'GET', token, null ) orgWarehouseIds = (whRes?.records || []).map((w: any) => w.id).filter(Boolean) console.log('[product-lookup] Warehouses for org', productOrgId, ':', orgWarehouseIds) } if (orgWarehouseIds.length > 0) { // Use warehouses that belong to the product's org const whFilterParts = orgWarehouseIds.map((id: any) => 'M_Warehouse_ID eq ' + id) locatorFilter = '(' + whFilterParts.join(' OR ') + ') AND AD_Org_ID eq ' + productOrgId + ' AND ' + locatorFilter } else if (productOrgId) { // Fallback: org-only filter locatorFilter = 'AD_Org_ID eq ' + productOrgId + ' AND ' + locatorFilter } else if (warehouseId) { // No org available, use session warehouse locatorFilter = 'M_Warehouse_ID eq ' + warehouseId + ' AND ' + locatorFilter } let locatorsRes: any = await fetchHelper( event, `models/m_locator?$filter=${string.urlEncode(locatorFilter)}&$orderby=${string.urlEncode('IsDefault desc, PriorityNo desc')}`, 'GET', token, null ) console.log('[product-lookup] Locators for product org (with warehouse):', locatorsRes?.records?.length || 0) // If no locators found with warehouse filter and product org differs, try without warehouse filter if ((!locatorsRes?.records || locatorsRes.records.length === 0) && productOrgId) { console.log('[product-lookup] Retrying locator search with only org filter for orgId:', productOrgId) locatorsRes = await fetchHelper( event, `models/m_locator?$filter=${string.urlEncode('AD_Org_ID eq ' + productOrgId + ' AND IsActive eq true')}&$orderby=${string.urlEncode('IsDefault desc, PriorityNo desc')}`, 'GET', token, null ) console.log('[product-lookup] Locators for product org (org only):', locatorsRes?.records?.length || 0) } if (locatorsRes?.records && locatorsRes.records.length > 0) { // Carry the REAL on-hand qty per locator (from the storage aggregation // above) so the client can show/select locators with their available qty. availableLocators = locatorsRes.records.map((loc: any) => ({ id: loc.id, code: (loc.X || loc.Y || loc.Z) ? [loc.X, loc.Y, loc.Z].filter(Boolean).join(' - ') : (loc.Value || loc.identifier || loc.Name), qtyOnHand: locatorMap[loc.id]?.qtyOnHand || 0, isDefault: loc.IsDefault || false, warehouseId: loc.M_Warehouse_ID?.id || loc.M_Warehouse_ID, orgId: loc.AD_Org_ID?.id || loc.AD_Org_ID })) // If no locators with qty, use the default locator or first locator as initial if (locators.length === 0) { const defaultLocator = availableLocators.find(l => l.isDefault) || availableLocators[0] if (defaultLocator) { locators = [defaultLocator] } } } } // Extract warehouse ID - prioritize from org's warehouses, then availableLocators, then session fallback // Use the org's own warehouse (not warehouseIdFromStorage which may be wrong) let warehouseIdForProduct = orgWarehouseIds.length > 0 ? orgWarehouseIds[0] : null if (!warehouseIdForProduct && availableLocators.length > 0 && availableLocators[0].warehouseId) { warehouseIdForProduct = availableLocators[0].warehouseId } if (!warehouseIdForProduct) { warehouseIdForProduct = body?.warehouseId } data = { id: product.id, Name: product.Name, Value: product.Value, SKU: product.SKU, UPC: product.UPC, MPN: product.mpn || null, ASIN: product.asin || null, FNSKU: product.fnsku || null, isBOM: (product.IsBOM ?? product.isBOM) === true || (product.IsBOM ?? product.isBOM) === 'Y', isJtlBom: (product.isJtlBom ?? product.IsJtlBom) === true || (product.isJtlBom ?? product.IsJtlBom) === 'Y', Description: product.Description, ImageURL: product.ImageURL || null, labelPrinterId: product.labelprinter_rma?.id || null, labelPrinter: product.labelprinter_rma?.identifier || null, qtyonhand: qtyonhand, returnQtyOnHand: returnQtyOnHand, qtyreserved: product.QtyReserved || 0, qtyavailable: product.QtyAvailable || 0, qtyordered: product.QtyOrdered || 0, // Sales orders (customer orders) qtypurchased: product.QtyOrdered_PO || 0, // Purchase orders Weight: product.Weight || 0, ShelfWidth: product.ShelfWidth || 0, ShelfHeight: product.ShelfHeight || 0, ShelfDepth: product.ShelfDepth || 0, Strapi_Product_documentId: product.Strapi_Product_documentId || null, AD_Org_ID: product.AD_Org_ID || null, M_Warehouse_ID: warehouseIdForProduct, locators: locators, availableLocators: availableLocators } } else { throw createError({ statusCode: 404, statusMessage: 'Product not found' }) } return data } export default defineEventHandler(async (event) => { // Auth-only retry: business errors (404 "Product not found" etc.) pass // through without the former blanket token refresh — see withAuthRetry. return await withAuthRetry(event, handleFunc) })