import { string } from 'alga-js' import refreshTokenHelper from "../../../utils/refreshTokenHelper" import forceLogoutHelper from "../../../utils/forceLogoutHelper" import errorHandlingHelper from "../../../utils/errorHandlingHelper" import fetchHelper from "../../../utils/fetchHelper" import getTokenHelper from "../../../utils/getTokenHelper" // iDempiere shipment generation (MStorageOnHand.getWarehouse) allocates stock // ORDER BY l.PriorityNo DESC — the locator with the HIGHER PriorityNo is picked // first; equal priorities fall back to material-policy date, which splits a // product's picks across locators ("round robin"). This endpoint finds products // with qty on hand on 2+ locators inside the SAME warehouse and proposes // PriorityNo values so the locator with the LOWEST Z (lowest level, easiest to // reach) is picked first. Ties broken by X, then Y, then locator Value. // X/Y/Z are VARCHAR in iDempiere — compare numeric when both parse, natural-sort // otherwise. Empty/unknown sorts LAST (→ lowest priority → picked last). const compareCoord = (a: any, b: any): number => { const sa = String(a ?? '').trim() const sb = String(b ?? '').trim() if (sa === '' && sb === '') return 0 if (sa === '') return 1 if (sb === '') return -1 const na = Number(sa) const nb = Number(sb) if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb return sa.localeCompare(sb, undefined, { numeric: true }) } // Desired pick order: lowest Z first, then X, Y, Value, id as deterministic tie-breaks. const compareLocatorsAsc = (l1: any, l2: any): number => { return compareCoord(l1.z, l2.z) || compareCoord(l1.x, l2.x) || compareCoord(l1.y, l2.y) || String(l1.value ?? '').localeCompare(String(l2.value ?? ''), undefined, { numeric: true }) || (Number(l1.id) - Number(l2.id)) } const handleFunc = async (event: any, authToken: any = null) => { const token = authToken ?? await getTokenHelper(event) const query = getQuery(event) const orgId = query?.org_id as string if (!orgId) { return { status: 400, message: 'Organization ID is required', records: [], 'row-count': 0, summary: null } } // Step 1: All locators of the org (full records so M_Warehouse_ID is present) const locatorMap = new Map() let locatorsCapped = false const locatorOrgFilter = string.urlEncode(`AD_Org_ID eq ${orgId}`) const locatorRes: any = await fetchHelper( event, `models/m_locator?$filter=${locatorOrgFilter}&$expand=M_LocatorType_ID&$top=5000`, 'GET', token, null ) if (locatorRes?.records) { locatorsCapped = locatorRes.records.length >= 5000 for (const loc of locatorRes.records) { locatorMap.set(loc.id, { id: loc.id, value: loc.Value || '', x: loc.X ?? '', y: loc.Y ?? '', z: loc.Z ?? '', priorityNo: Number(loc.PriorityNo ?? 0), warehouseId: loc.M_Warehouse_ID?.id ?? null, warehouseName: loc.M_Warehouse_ID?.identifier ?? '', isActive: loc.IsActive === true || loc.IsActive === 'Y', locatorTypeName: loc.M_LocatorType_ID?.Name || '' }) } } // Step 2: Products of the org with their storage records const orgFilter = string.urlEncode(`AD_Org_ID eq ${orgId} and IsActive eq true`) const res: any = await fetchHelper( event, `models/m_product?$select=${string.urlEncode('M_Product_ID,Name,SKU,UPC,Value,mpn,AD_Org_ID')}&$expand=M_Storage,AD_Org_ID($select=Name)&$filter=${orgFilter}&$orderby=${string.urlEncode('Name asc')}&$top=5000`, 'GET', token, null ) if (!res?.records) { return { status: 200, records: [], 'row-count': 0, summary: null, capped: locatorsCapped } } const productsCapped = res.records.length >= 5000 // Step 3: Per product, sum qty per locator (ASI rows sum first), keep qty > 0, // then group by warehouse — a (product, warehouse) group with 2+ locators is a // conflict group. Stock split across DIFFERENT warehouses is NOT a conflict // (shipment generation is warehouse-scoped). let skippedStorageRows = 0 // conflictGroups: { product, warehouseId, locators: [{ locator, qty }] } const conflictGroups: any[] = [] for (const product of res.records) { const storages = product?.M_Storage || [] if (!Array.isArray(storages) || storages.length === 0) continue const qtyByLocator = new Map() for (const storage of storages) { const locId = storage?.M_Locator_ID?.id if (!locId || !locatorMap.has(locId)) { skippedStorageRows++ continue } qtyByLocator.set(locId, (qtyByLocator.get(locId) ?? 0) + Number(storage?.QtyOnHand || 0)) } const byWarehouse = new Map() for (const [locId, qty] of qtyByLocator) { if (qty <= 0) continue const locator = locatorMap.get(locId) if (locator.warehouseId == null) continue if (!byWarehouse.has(locator.warehouseId)) byWarehouse.set(locator.warehouseId, []) byWarehouse.get(locator.warehouseId)!.push({ locator, qty }) } for (const [warehouseId, entries] of byWarehouse) { if (entries.length >= 2) { conflictGroups.push({ product, warehouseId, entries }) } } } // Step 4: Violation check per conflict group — locators in desired pick order // (compareLocatorsAsc) must have STRICTLY decreasing PriorityNo. Equal counts // as violated (ambiguous order is exactly the round-robin symptom). const involvedByWarehouse = new Map>() const violatedWarehouses = new Set() const violatedProductWh = new Set() for (const group of conflictGroups) { group.entries.sort((a: any, b: any) => compareLocatorsAsc(a.locator, b.locator)) if (!involvedByWarehouse.has(group.warehouseId)) involvedByWarehouse.set(group.warehouseId, new Set()) const involved = involvedByWarehouse.get(group.warehouseId)! for (const entry of group.entries) involved.add(entry.locator.id) let violated = false for (let i = 1; i < group.entries.length; i++) { if (group.entries[i].locator.priorityNo >= group.entries[i - 1].locator.priorityNo) { violated = true break } } group.violated = violated if (violated) { violatedWarehouses.add(group.warehouseId) violatedProductWh.add(`${group.product.id}_${group.warehouseId}`) } } // Step 5: Rank-based proposals per violated warehouse — sort the warehouse's // involved locators into desired pick order and assign strictly decreasing // priorities (first/lowest Z gets the highest number). Deterministic and // idempotent: re-running after apply yields proposed == current everywhere. const proposals = new Map() for (const warehouseId of violatedWarehouses) { const involved = Array.from(involvedByWarehouse.get(warehouseId)!) .map(id => locatorMap.get(id)) .sort(compareLocatorsAsc) const n = involved.length const step = n * 10 <= 9999 ? 10 : Math.max(1, Math.floor(9999 / n)) for (let i = 0; i < n; i++) { proposals.set(involved[i].id, (n - i) * step) } } // Step 6: Build response rows — one per (conflict-group product, locator) const records: any[] = [] for (const group of conflictGroups) { const product = group.product const productId = product.M_Product_ID || product.id for (const entry of group.entries) { const locator = entry.locator const proposal = proposals.get(locator.id) const proposedPriority = proposal !== undefined && proposal !== locator.priorityNo ? proposal : null records.push({ id: `${productId}_${locator.id}`, kind: 'priority', productId, productName: product.Name || '', productValue: product.Value || '', sku: product.SKU || '', ean: product.UPC || '', mpn: product.mpn || '', warehouseId: group.warehouseId, warehouseName: locator.warehouseName, locatorId: locator.id, locatorValue: locator.value, locatorX: locator.x, locatorY: locator.y, locatorZ: locator.z, locatorTypeName: locator.locatorTypeName, locatorIsActive: locator.isActive, qtyOnHand: entry.qty, currentPriority: locator.priorityNo, proposedPriority, status: proposedPriority !== null ? 'change' : 'ok', productViolated: group.violated === true }) } } records.sort((a, b) => String(a.warehouseName).localeCompare(String(b.warehouseName)) || String(a.productName).localeCompare(String(b.productName)) || compareLocatorsAsc( { z: a.locatorZ, x: a.locatorX, y: a.locatorY, value: a.locatorValue, id: a.locatorId }, { z: b.locatorZ, x: b.locatorX, y: b.locatorY, value: b.locatorValue, id: b.locatorId } ) ) const changedLocatorIds = new Set(records.filter(r => r.status === 'change').map(r => r.locatorId)) return { status: 200, records, 'row-count': records.length, summary: { productsMultiLocator: new Set(conflictGroups.map(g => `${g.product.id}_${g.warehouseId}`)).size, productsViolated: violatedProductWh.size, warehousesAffected: violatedWarehouses.size, locatorsToChange: changedLocatorIds.size, skippedStorageRows }, capped: locatorsCapped || productsCapped } } 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 })