import { string } from 'alga-js' import refreshTokenHelper from "../../../utils/refreshTokenHelper" import getTokenHelper from "../../../utils/getTokenHelper" import forceLogoutHelper from "../../../utils/forceLogoutHelper" import errorHandlingHelper from "../../../utils/errorHandlingHelper" import fetchHelper from "../../../utils/fetchHelper" import getReturnLocatorIds from "../../../utils/returnLocatorHelper" // Org-scoped product search for the Qty-Fix (duplicate cleanup) tool. // Returns matching products with their current (non-return) qty on hand so the // operator can quickly see which duplicate holds the (wrongly added) stock. const handleFunc = async (event: any, authToken: any = null) => { let data: any = { records: [] } const token = authToken ?? await getTokenHelper(event) const query = getQuery(event) const orgId = query?.org_id as string const searchTerm = ((query?.search as string) || '').trim() // The SOURCE (wrong/duplicate) side may legitimately be a BOM article that wrongly // holds stock, so it asks for BOMs to be INCLUDED. The TARGET (correct) side must // never be a BOM, so it omits this flag and keeps the exclusion. const includeBom = ['1', 'true', 'yes'].includes(String(query?.include_bom ?? '').toLowerCase()) // "Show all" mode (source side): browse the org's products WITHOUT a search term and let // the client-side checkbox filters (stock / BOM) narrow them. Returns a larger bounded page. const loadAll = ['1', 'true', 'yes'].includes(String(query?.load_all ?? '').toLowerCase()) if (!orgId) { return { status: 400, message: 'Organization ID is required', records: [] } } if (searchTerm.length < 2 && !loadAll) { return data } const escaped = searchTerm.replace(/'/g, "''").toLowerCase() // Strict org scope (no AD_Org_ID 0) — duplicate stock to be moved is org-owned. // BOM articles are excluded for the TARGET side only: IsBOM is the standard iDempiere // flag (always present → filtered server-side); isJtlBom is a custom column that // may be absent in some envs, so it's filtered in JS below (NOT in $filter, which // would 500 where the column doesn't exist). When includeBom is set (source side), // both filters are skipped so any BOM can be picked. const bomClause = includeBom ? '' : ' AND IsBOM eq false' // The name/SKU/EAN match is only added when a term is present; in load-all mode it is // omitted so the whole (bounded) org product list comes back for client-side filtering. const termClause = searchTerm.length >= 2 ? `(contains(tolower(Value),'${escaped}') OR contains(tolower(Name),'${escaped}') OR contains(tolower(SKU),'${escaped}') OR contains(tolower(mpn),'${escaped}') OR contains(tolower(UPC),'${escaped}')) AND ` : '' const searchFilter = `${termClause}AD_Org_ID eq ${orgId} AND IsActive eq true${bomClause}` // Load-all returns a larger bounded page (client then filters by stock/BOM); a normal // search stays tight at 30. const top = loadAll ? 200 : 30 // No $select: fetch full records (matches the mobile product-lookup) so optional // custom columns like isJtlBom come along whether or not they exist in this env // (adding a missing column to $select would 500 the whole query). const res: any = await fetchHelper( event, `models/m_product?$filter=${string.urlEncode(searchFilter)}&$expand=M_Storage&$top=${top}&$orderby=${string.urlEncode('Name asc')}`, 'GET', token, null ) const returnLocatorIds = await getReturnLocatorIds(event, token, orgId) data.records = (res?.records || []).filter((p: any) => { // Hide JTL-BOM articles (custom column; filtered here rather than in $filter // because it may not exist in every env). Standard IsBOM is excluded server-side. // Skipped entirely for the source side (includeBom) so any BOM stays selectable. if (includeBom) return true const isJtlBom = (p.isJtlBom ?? p.IsJtlBom) === true || (p.isJtlBom ?? p.IsJtlBom) === 'Y' return !isJtlBom }).map((p: any) => { let qtyOnHand = 0 let returnQtyOnHand = 0 const storages = Array.isArray(p?.M_Storage) ? p.M_Storage : [] for (const s of storages) { const qty = Number(s?.QtyOnHand ?? 0) if (!qty) continue const locId = s?.M_Locator_ID?.id ?? s?.M_Locator_ID if (locId !== undefined && locId !== null && returnLocatorIds.has(Number(locId))) { returnQtyOnHand += qty } else { qtyOnHand += qty } } return { id: p.M_Product_ID || p.id, Name: p.Name, Value: p.Value, SKU: p.SKU, UPC: p.UPC, MPN: p.mpn || null, isJtlBom: (p.isJtlBom ?? p.IsJtlBom) === true || (p.isJtlBom ?? p.IsJtlBom) === 'Y', isBom: (p.IsBOM ?? p.isBom) === true || (p.IsBOM ?? p.isBom) === 'Y', qtyOnHand, returnQtyOnHand } }) // Flag when the load-all page hit its cap (more org products exist than returned) so the // UI can hint the operator to narrow with a search. data.capped = loadAll && (res?.records?.length || 0) >= top return data } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { try { let authToken: any = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error: any) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) forceLogoutHelper(event, data) } } return data })