// Shared body of the products AG Grid server-row endpoints // (materials/products/ag-grid/products + products-v2). The v1 endpoint's // response is byte-identical to before the extraction; v2 passes `extraSelect` // + `mapExtra` to add columns without duplicating the filter/sort/storage logic. import { string } from 'alga-js' import getReturnLocatorIds from './returnLocatorHelper' import { fetchProductsWithStorage } from './productStorageHelper' const idempiereFilterName = (key: string) => { const map: Record = { name: 'Name', value: 'Value', sku: 'SKU', mpn: 'mpn', upc: 'UPC', weight: 'Weight', width: 'Width', length: 'Length', height: 'Height', volume: 'Volume', isBOM: 'IsBOM', organization: 'AD_Org_ID', qtyonhand: 'QtyOnHand', taricCode: 'taricCode' } return map[key] || '' } const numberFilterHandler = (key: string, item: any) => { let newNumberFilter = '' if (item.type == 'equals') { newNumberFilter = `${key} eq ${item.filter}` } else if (item.type == 'notEqual') { newNumberFilter = `${key} neq ${item.filter}` } else if (item.type == 'greaterThan') { newNumberFilter = `${key} gt ${item.filter}` } else if (item.type == 'greaterThanOrEqual') { newNumberFilter = `${key} ge ${item.filter}` } else if (item.type == 'lessThan') { newNumberFilter = `${key} lt ${item.filter}` } else if (item.type == 'lessThanOrEqual') { newNumberFilter = `${key} le ${item.filter}` } else if (item.type == 'inRange') { newNumberFilter = `${key} ge ${item.filter} AND ${key} le ${item.filterTo}` } return newNumberFilter } const textFilterHandler = (key: string, item: any) => { let newTextFilter = '' if (item.type == 'equals') { newTextFilter = `${key} eq ${item.filter}` } else if (item.type == 'notEqual') { newTextFilter = `${key} neq ${item.filter}` } else if (item.type == 'contains') { newTextFilter = `contains(${key},${item.filter.replaceAll('%', '')})` } else if (item.type == 'notContains') { newTextFilter = `not contains(${key},${item.filter.replaceAll('%', '')})` } else if (item.type == 'startsWith') { newTextFilter = `startswith(${key},'${item.filter}')` } else if (item.type == 'endsWith') { newTextFilter = `endswith(${key},'${item.filter}')` } return newTextFilter } export const productsFilterHandler = (filterModel: any) => { let newFilter = '' for (const key of Object.keys(filterModel ?? {})) { const filterName = idempiereFilterName(key) if (!filterName) continue if (newFilter !== '') { newFilter += ' AND ' } const item = filterModel[key] if (item.filterType == 'text') { newFilter += textFilterHandler(filterName, item) } else if (item.filterType == 'number') { newFilter += numberFilterHandler(filterName, item) } } if (newFilter !== '') { newFilter = ` AND (${newFilter})` } return newFilter } export const productsSortHandler = (sortModel: any[]) => { if (!sortModel || sortModel.length === 0) { return 'M_Product_ID desc' } const parts = sortModel.map((s: any) => { const col = idempiereFilterName(s.colId) if (!col) return '' return `${col} ${s.sort}` }).filter(Boolean) return parts.length > 0 ? parts.join(',') : 'M_Product_ID desc' } export const splitStorageByLocator = (storages: any[], returnLocatorIds: Set) => { let onHand = 0 let returnOnHand = 0 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 && returnLocatorIds.has(Number(locId))) { returnOnHand += qty } else { onHand += qty } } return { onHand, returnOnHand } } // NB: isJtlBom/taricCode are custom columns with lowercase-first ColumnName — PascalCase // ('IsJtlBom'/'TaricCode') is rejected by iDempiere's $select and would break the whole grid. const BASE_SELECT = ['M_Product_ID', 'M_Product_UU', 'Name', 'Value', 'SKU', 'mpn', 'UPC', 'Weight', 'Width', 'Length', 'Height', 'Volume', 'IsBOM', 'isJtlBom', 'AD_Org_ID', 'Strapi_Product_documentId', 'CompletedAllMarketplaceData', 'CustomFulfillmentPrice', 'isCustomFulfillmentPrice', 'CUST_FulfillmentProductPricingRules_ID', 'labelprinter', 'taricCode', 'C_Country_ID', 'IsSummary', 'ParentId'] export interface ProductsGridOptions { /** extra $select columns (iDempiere ColumnName casing) */ extraSelect?: string[] /** extra OData filter fragment, already prefixed with ' AND ' */ extraFilter?: string /** enrich a mapped row from the raw iDempiere record (may be async) */ mapExtra?: (item: any, row: any) => void | Promise } export const runProductsGrid = async (event: any, token: string, body: any, opts: ProductsGridOptions = {}) => { const data: any = { rows: [], lastRow: 0 } const startRow = body.startRow const endRow = body.endRow const pageSize = (endRow - startRow) + 1 const orderBy = productsSortHandler(body.sortModel) const select = string.urlEncode([...BASE_SELECT, ...(opts.extraSelect ?? [])].join(',')) // Build org filter from toolbar switch. // Include org 0 ('*') so shared/client-level product masters aren't filtered out // when a concrete org is selected (mirrors materials/products/search.get.ts). let orgFilter = '' if (body.orgId) { orgFilter = ` AND (AD_Org_ID eq ${body.orgId} OR AD_Org_ID eq 0)` } // By default hide bill-of-materials articles (standard BOM + JTL-BOM). The // products-list toolbar can opt back in via `includeBom`. NB: iDempiere's OData // `ne`/`not(...)` are unsupported here, so we use `eq false` (which also drops // the handful of rows whose flag is null — acceptable, the toggle reveals them). let bomFilter = '' if (!body.includeBom) { bomFilter = ' AND IsBOM eq false AND isJtlBom eq false' } const [res, returnLocatorIds]: any = await Promise.all([ // C_Country_ID is expanded because the flag needs the country's CountryCode — // the plain FK object only carries id + identifier. fetchProductsWithStorage(event, token, `models/m_product?$select=${select}&$expand=C_Country_ID&$filter=${string.urlEncode('(isActive eq true OR isActive eq false)' + orgFilter + bomFilter + (opts.extraFilter ?? '') + productsFilterHandler(body.filterModel))}&$orderby=${string.urlEncode(orderBy)}&$top=${pageSize}&$skip=${startRow}`), getReturnLocatorIds(event, token, body?.orgId || null) ]) if (res) { const allRecords: any[] = [] for (const item of res['records'] ?? []) { const { onHand, returnOnHand } = splitStorageByLocator(item?.M_Storage ?? [], returnLocatorIds) const row: any = { id: item.id, uid: item.uid, name: item.Name, value: item.Value, sku: item.SKU, mpn: item.mpn, upc: item.UPC, weight: item.Weight, width: item.width || item.Width || 0, length: item.length || item.Length || 0, height: item.height || item.Height || 0, volume: item.volume || item.Volume || 0, isBOM: item.IsBOM, // variant grouping: master = IsSummary (structure entity), variation = ParentId → master Value isSummary: item.IsSummary ?? false, parentId: item.ParentId ?? item.parentId ?? '', // AD_Column name is `parentId` isJtlBom: item.IsJtlBom ?? item.isJtlBom ?? false, organization: item.AD_Org_ID?.identifier || '', organizationId: item.AD_Org_ID?.id || '', strapiProductId: item.Strapi_Product_documentId, completedAllMarketplaceData: item.CompletedAllMarketplaceData, qtyonhand: onHand, returnQtyOnHand: returnOnHand, customFulfillmentPrice: item.CustomFulfillmentPrice || 0, isCustomFulfillmentPrice: item.isCustomFulfillmentPrice ?? false, fulfillmentProductPricingRules: item.CUST_FulfillmentProductPricingRules_ID?.identifier || '', fulfillmentProductPricingRulesId: item.CUST_FulfillmentProductPricingRules_ID?.id || '', labelPrinter: item.labelprinter?.identifier || '', labelPrinterId: item.labelprinter?.id || '', taricCode: item.taricCode ?? '', // expanded FK: full c_country record (identifier is null when expanded) originCountry: item.C_Country_ID?.Name ?? item.C_Country_ID?.identifier ?? '', originCountryCode: item.C_Country_ID?.CountryCode || '', originCountryId: item.C_Country_ID?.id || '' } if (opts.mapExtra) await opts.mapExtra(item, row) allRecords.push(row) } data.rows = allRecords data.lastRow = res['row-count'] || 0 } return data }