// Shared item model for the DIGITAL picklist (): turning // m_inout records into pickable lines, aggregating them and sorting them. // // Every host (/generate-shipments panel, /integrations/picklist-desktop, /mobile/picklist, // /mobile/generate-shipments) feeds the component with the lines returned by // `pickLinesFromInOut(record)` — the item lines of one shipment, each tagged with the // shipment it came from (`_docNo`, `_shipper`, `_partner`). The component aggregates // them per org + product + locator (same key as the printed picklist) and keeps the // shipment / shipper sets per aggregated row so the picker can sort by them WITHOUT // splitting a row that is shared by several shipments. // // The PDF paths of the hosts keep their own (older) aggregation + `sortPicklistData`; // this file is only for the digital list. import { resolvePickCode, getProductCodeValue, PICK_CODE_KEYS } from './pickCode' export interface PicklistShipmentRef { docNo: string id: any partner: string org: string shipper: string lineCount: number totalQty: number } export const PICKLIST_SORT_KEYS = ['locator', 'pickcode', 'value', 'sku', 'mpn', 'qty', 'shipper', 'docno', 'org'] as const export type PicklistSortKey = typeof PICKLIST_SORT_KEYS[number] // A shipment line counts as a pickable item only when its product is ProductType 'I' // (iDempiere "Item" — physical stock, not services/charges). iDempiere returns list // fields as { id: 'I', identifier: 'Item' } when the product is expanded; plain GETs // may return a bare string instead. export const isItemProduct = (product: any): boolean => { const raw = product?.ProductType ?? product?.producttype const code = (raw && typeof raw === 'object') ? (raw.id ?? raw.identifier ?? '') : (raw ?? '') return code.toString().trim().toUpperCase() === 'I' } const fkName = (fk: any): string => { if (!fk) return '' if (typeof fk === 'object') return (fk.identifier ?? fk.Name ?? fk.name ?? '').toString() return fk.toString() } // Item lines of one m_inout record (from /api/processes/inouts/{docNo}), each tagged with // its shipment. Keeps only physical items with a locator and no charge. export const pickLinesFromInOut = (record: any): any[] => { if (!record) return [] const docNo = (record.DocumentNo ?? '').toString() const shipper = fkName(record.M_Shipper_ID) const partner = fkName(record.C_BPartner_ID) const org = fkName(record.AD_Org_ID) return (record.m_inoutline || []) .filter((l: any) => l?.M_Product_ID && l?.M_Locator_ID && !l?.C_Charge_ID && isItemProduct(l.M_Product_ID)) .map((l: any) => ({ ...l, _docNo: docNo, _inoutId: record.id, _shipper: shipper, _partner: partner, _org: org })) } // Stable identity of an aggregated row: org + product + locator slot (same as the PDF). export const picklistItemId = (line: any): string => `${line?.AD_Org_ID?.id}-${line?.M_Product_ID?.Value}-${line?.M_Locator_ID?.X}-${line?.M_Locator_ID?.Y}-${line?.M_Locator_ID?.Z}` // One shipment summary per distinct `_docNo` in the given lines. export const picklistShipmentsFromLines = (lines: any[]): PicklistShipmentRef[] => { const map = new Map() for (const line of lines || []) { const docNo = (line?._docNo ?? '').toString() if (!docNo) continue let ref = map.get(docNo) if (!ref) { ref = { docNo, id: line._inoutId, partner: line._partner || '', org: line._org || fkName(line.AD_Org_ID), shipper: line._shipper || '', lineCount: 0, totalQty: 0 } map.set(docNo, ref) } ref.lineCount += 1 ref.totalQty += Number(line?.QtyEntered || 0) } return [...map.values()] } // Group the same product/locator across all shipments. The result rows carry the // summed quantity plus `_docNos` / `_shippers` (sorted, unique) for display and sorting. export const aggregatePicklistLines = (lines: any[]): any[] => { const byId = new Map() for (const line of lines || []) { const id = picklistItemId(line) const existing = byId.get(id) if (existing) { existing.QtyEntered = Number(existing.QtyEntered) + Number(line.QtyEntered || 0) if (line._docNo && !existing._docNos.includes(line._docNo)) existing._docNos.push(line._docNo) if (line._shipper && !existing._shippers.includes(line._shipper)) existing._shippers.push(line._shipper) } else { byId.set(id, { ...line, _id: id, QtyEntered: Number(line.QtyEntered || 0), _docNos: line._docNo ? [line._docNo] : [], _shippers: line._shipper ? [line._shipper] : [], }) } } const rows = [...byId.values()] for (const row of rows) { row._docNos.sort((a: string, b: string) => a.localeCompare(b, undefined, { numeric: true })) row._shippers.sort() } return rows } const natural = (a: any, b: any) => (a ?? '').toString().localeCompare((b ?? '').toString(), undefined, { numeric: true, sensitivity: 'base' }) // Ranking used by the "pick X first" override (locator sort only): rotates the aisle // order so `startX` leads, the aisles "after" it follow, then the ones before wrap round. export const locatorRotationRank = (x: any, startX: any, dir: number) => { const c = natural(x, startX) if (dir === -1) return c <= 0 ? 0 : 1 return c >= 0 ? 0 : 1 } // The value the primary pick code resolves to (partner identifier → product PickListCode → fallback). export const primaryPickCodeValue = (item: any): string => { const resolved = resolvePickCode(item?.M_Product_ID, item?.pickIdentifier) return resolved ? getProductCodeValue(item.M_Product_ID, resolved.key) : '' } export interface PicklistSortOptions { sortBy: PicklistSortKey | '' dir?: 'asc' | 'desc' startX?: string } // Organization is always the primary grouping (ascending); `dir` only flips the chosen field. export const sortPicklistItems = (items: any[], { sortBy, dir = 'asc', startX = '' }: PicklistSortOptions): any[] => { const d = dir === 'desc' ? -1 : 1 return [...(items || [])].sort((a, b) => { if (sortBy !== 'org') { const orgCompare = natural(a.AD_Org_ID?.identifier, b.AD_Org_ID?.identifier) if (orgCompare !== 0) return orgCompare } let cmp = 0 switch (sortBy) { case 'org': cmp = natural(a.AD_Org_ID?.identifier, b.AD_Org_ID?.identifier) break case 'value': cmp = natural(a.M_Product_ID?.Value, b.M_Product_ID?.Value) break case 'sku': cmp = natural(a.M_Product_ID?.SKU, b.M_Product_ID?.SKU) break case 'mpn': cmp = natural(a.M_Product_ID?.mpn || a.M_Product_ID?.MPN, b.M_Product_ID?.mpn || b.M_Product_ID?.MPN) break case 'pickcode': cmp = natural(primaryPickCodeValue(a), primaryPickCodeValue(b)) break case 'qty': // asc = lowest first, desc = highest first cmp = Number(a.QtyEntered) - Number(b.QtyEntered) break case 'shipper': cmp = natural((a._shippers || []).join(', '), (b._shippers || []).join(', ')) break case 'docno': cmp = natural((a._docNos || [])[0], (b._docNos || [])[0]) break case 'locator': { if (startX) { const aRank = locatorRotationRank(a.M_Locator_ID?.X, startX, d) const bRank = locatorRotationRank(b.M_Locator_ID?.X, startX, d) if (aRank !== bRank) return aRank - bRank } cmp = natural(a.M_Locator_ID?.X, b.M_Locator_ID?.X) || natural(a.M_Locator_ID?.Y, b.M_Locator_ID?.Y) || natural(a.M_Locator_ID?.Z, b.M_Locator_ID?.Z) break } default: return 0 } // Stable tie-break so equal keys keep the walking order through the warehouse. if (cmp === 0 && sortBy !== 'locator') { cmp = natural(a.M_Locator_ID?.X, b.M_Locator_ID?.X) || natural(a.M_Locator_ID?.Y, b.M_Locator_ID?.Y) || natural(a.M_Locator_ID?.Z, b.M_Locator_ID?.Z) return cmp } return d * cmp }) } // All codes of a product a scanner / search may hit (upper-cased, trimmed). export const productScanCodes = (product: any): string[] => { const codes: string[] = [] for (const key of PICK_CODE_KEYS) { const v = getProductCodeValue(product, key) if (v) codes.push(v.toString().trim().toUpperCase()) } return codes } // Does a scanned/typed code identify this item? Exact match on any product code; an // EAN-13 scan also matches a stored UPC-A (12 digits) with the leading zero dropped. export const itemMatchesScan = (item: any, code: string): boolean => { const needle = (code ?? '').toString().trim().toUpperCase() if (!needle) return false const codes = productScanCodes(item?.M_Product_ID) if (codes.includes(needle)) return true if (/^\d{13}$/.test(needle) && needle.startsWith('0') && codes.includes(needle.slice(1))) return true if (/^\d{12}$/.test(needle) && codes.includes('0' + needle)) return true return false }