/** * Locator catalog — on-demand locator creation per organization (merchant). * * iDempiere needs one M_Warehouse per AD_Org and every locator exists per warehouse, * so "AA-01-01" must be created once for every merchant that stores goods there. * Instead of pre-creating all ~500 bins for every org, the MASTER org * (runtimeConfig.locatorMasterOrgId, default 1000000 = LogYou) holds the complete * set and acts as the template catalog: * * - listLocatorCatalog(): the org's own locators + every master locator the org does * not have yet, flagged `isVirtual: true` with id `new:` (pickers show them * as a preview). * - ensureLocator(): resolve a code / X-Y-Z / role ('default' | 'return') for an org — * returns the existing locator or CREATES it in the org's warehouse from the master * template (Value, X, Y, Z, PriorityNo, M_LocatorType_ID). Never creates codes that * are unknown to the master org (typos stay 404). * * Mobile + desktop pages reach this through /api/mobile/locators, /api/mobile/locator- * ensure and the existing lookup routes (locator-lookup, locator-by-xyz, * default-locator-lookup, reconditioning/target-locators, product-lookup) — client side * via the `useLocatorCatalog()` composable. */ import { string } from 'alga-js' import fetchHelper from './fetchHelper' import { odataQuote } from './odataHelper' export const VIRTUAL_PREFIX = 'new:' export const isVirtualId = (id: any): boolean => typeof id === 'string' && id.startsWith(VIRTUAL_PREFIX) export const masterOrgId = (): number => Number((useRuntimeConfig() as any).locatorMasterOrgId) || 1000000 const SELECT = 'Value,X,Y,Z,IsDefault,PriorityNo,M_Warehouse_ID,AD_Org_ID,M_LocatorType_ID' const EXPAND = 'M_LocatorType_ID' const MASTER_TTL_MS = 2 * 60 * 1000 const WAREHOUSE_TTL_MS = 5 * 60 * 1000 export interface CatalogLocator { id: number | string code: string x: string y: string z: string isDefault: boolean priorityNo: number warehouseId: number | null organizationId: number | null locatorTypeId: number | null locatorType: string isShippable: boolean isReturn: boolean isVirtual: boolean templateId?: number } const isY = (v: any) => v === true || v === 'Y' const idOf = (v: any): number | null => (v && typeof v === 'object' ? Number(v.id) : Number(v)) || null const enc = (v: string) => string.urlEncode(v) export const mapLocator = (l: any, isVirtual = false): CatalogLocator => { const lt = l?.M_LocatorType_ID && typeof l.M_LocatorType_ID === 'object' ? l.M_LocatorType_ID : null const shippable = lt ? isY(lt.IsAvailableForShipping) : true return { id: isVirtual ? VIRTUAL_PREFIX + String(l.Value || '') : l.id, code: String(l.Value || l.identifier || l.Name || ''), x: String(l.X ?? ''), y: String(l.Y ?? ''), z: String(l.Z ?? ''), isDefault: !isVirtual && isY(l.IsDefault), priorityNo: Number(l.PriorityNo) || 50, warehouseId: idOf(l.M_Warehouse_ID), organizationId: idOf(l.AD_Org_ID), locatorTypeId: lt ? Number(lt.id) || null : idOf(l.M_LocatorType_ID), locatorType: lt ? String(lt.Name || lt.identifier || '') : '', // no locator type = plain bin (treated as shippable, like iDempiere does) isShippable: shippable, isReturn: lt ? (!shippable && !isY(lt.IsAvailableForReservation) && !isY(lt.IsAvailableForReplenishment)) : false, isVirtual, ...(isVirtual ? { templateId: Number(l.id) } : {}) } } /** "AA-01-01" / "AA - 01 - 01" → { x, y, z } (null when the code has no 3-part form). */ export const parseXyz = (code: string): { x: string; y: string; z: string } | null => { const m = String(code || '').trim().match(/^([^\s-]{1,8})\s*-\s*([^\s-]{1,8})\s*-\s*([^\s-]{1,8})$/) return m ? { x: m[1], y: m[2], z: m[3] } : null } const norm = (v: any) => String(v || '').trim().toLowerCase().replace(/\s+/g, '') // --- warehouse per org ------------------------------------------------------------------- const warehouseCache = new Map() /** The (one) active warehouse of an org — iDempiere needs a warehouse per org. */ export const resolveWarehouseForOrg = async (event: any, token: string, orgId: number): Promise => { if (!orgId) return null const hit = warehouseCache.get(orgId) if (hit && Date.now() - hit.at < WAREHOUSE_TTL_MS) return hit.id const res: any = await fetchHelper(event, `models/m_warehouse?$filter=${enc(`AD_Org_ID eq ${orgId} AND IsActive eq true`)}&$select=Name,Value&$orderby=${enc('M_Warehouse_ID asc')}&$top=5`, 'GET', token, null) const id = Number(res?.records?.[0]?.id) || null warehouseCache.set(orgId, { at: Date.now(), id }) return id } // --- master catalog ------------------------------------------------------------------------ let masterCache: { at: number; rows: any[] } | null = null /** Raw master-org locators (all active, with locator type), cached 2 min. */ export const listMasterLocators = async (event: any, token: string): Promise => { if (masterCache && Date.now() - masterCache.at < MASTER_TTL_MS) return masterCache.rows const res: any = await fetchHelper(event, `models/m_locator?$filter=${enc(`AD_Org_ID eq ${masterOrgId()} AND IsActive eq true`)}&$select=${SELECT}&$expand=${EXPAND}&$orderby=${enc('Value asc')}&$top=5000`, 'GET', token, null) const rows = res?.records || [] masterCache = { at: Date.now(), rows } return rows } const findMasterTemplate = (rows: any[], want: { code?: string; x?: string; y?: string; z?: string; role?: 'default' | 'return' }): any | null => { if (want.role === 'default') return rows.find((l) => isY(l.IsDefault)) || null if (want.role === 'return') { return rows.find((l) => { const lt = l?.M_LocatorType_ID return lt && typeof lt === 'object' && !isY(lt.IsAvailableForShipping) && !isY(lt.IsAvailableForReservation) && !isY(lt.IsAvailableForReplenishment) }) || null } if (want.code) { const c = norm(want.code) const byValue = rows.find((l) => norm(l.Value) === c) if (byValue) return byValue const xyz = parseXyz(want.code) if (xyz) return rows.find((l) => norm(l.X) === norm(xyz.x) && norm(l.Y) === norm(xyz.y) && norm(l.Z) === norm(xyz.z)) || null return null } if (want.x || want.y || want.z) { return rows.find((l) => norm(l.X) === norm(want.x) && norm(l.Y) === norm(want.y) && norm(l.Z) === norm(want.z)) || null } return null } // --- org catalog --------------------------------------------------------------------------- /** Active locators of one org (optionally one warehouse), raw records. */ export const listOrgLocators = async (event: any, token: string, orgId: number, warehouseId?: number | null): Promise => { let filter = `AD_Org_ID eq ${orgId} AND IsActive eq true` if (warehouseId) filter += ` AND M_Warehouse_ID eq ${warehouseId}` const res: any = await fetchHelper(event, `models/m_locator?$filter=${enc(filter)}&$select=${SELECT}&$expand=${EXPAND}&$orderby=${enc('Value asc')}&$top=5000`, 'GET', token, null) return res?.records || [] } /** * Org locators + master-only locators as virtual preview entries. Without `orgId` the * warehouse list is returned as is (no preview). The master org itself gets no preview. */ export const listLocatorCatalog = async (event: any, token: string, opts: { orgId?: number | null; warehouseId?: number | null }): Promise<{ locators: CatalogLocator[]; warehouseId: number | null; virtualCount: number }> => { const orgId = Number(opts.orgId) || 0 let warehouseId = Number(opts.warehouseId) || null if (!orgId) { if (!warehouseId) return { locators: [], warehouseId: null, virtualCount: 0 } const res: any = await fetchHelper(event, `models/m_locator?$filter=${enc(`M_Warehouse_ID eq ${warehouseId} AND IsActive eq true`)}&$select=${SELECT}&$expand=${EXPAND}&$orderby=${enc('Value asc')}&$top=5000`, 'GET', token, null) return { locators: (res?.records || []).map((l: any) => mapLocator(l)), warehouseId, virtualCount: 0 } } let own = await listOrgLocators(event, token, orgId, warehouseId) // warehouse-scoped list empty (stock booked into another warehouse) → whole org if (!own.length && warehouseId) own = await listOrgLocators(event, token, orgId, null) const locators = own.map((l: any) => mapLocator(l)) if (orgId === masterOrgId()) return { locators, warehouseId, virtualCount: 0 } if (!warehouseId) warehouseId = own[0] ? idOf(own[0].M_Warehouse_ID) : await resolveWarehouseForOrg(event, token, orgId) const have = new Set(locators.map((l) => norm(l.code))) const haveXyz = new Set(locators.map((l) => `${norm(l.x)}|${norm(l.y)}|${norm(l.z)}`)) let virtualCount = 0 for (const m of await listMasterLocators(event, token)) { const key = norm(m.Value) if (!key || have.has(key)) continue if ((m.X || m.Y || m.Z) && haveXyz.has(`${norm(m.X)}|${norm(m.Y)}|${norm(m.Z)}`)) continue const v = mapLocator(m, true) v.warehouseId = warehouseId v.organizationId = orgId locators.push(v) virtualCount++ } locators.sort((a, b) => a.code.localeCompare(b.code, 'de', { numeric: true })) return { locators, warehouseId, virtualCount } } // --- ensure (find or create) --------------------------------------------------------------- export interface EnsureRequest { orgId: number warehouseId?: number | null code?: string x?: string y?: string z?: string role?: 'default' | 'return' } const findInOrg = async (event: any, token: string, req: EnsureRequest): Promise => { const base = `AD_Org_ID eq ${req.orgId} AND IsActive eq true` let pred = '' if (req.role === 'default') pred = 'IsDefault eq true' else if (req.code) pred = `Value eq '${odataQuote(String(req.code).trim())}'` else if (req.x || req.y || req.z) pred = `X eq '${odataQuote(String(req.x || ''))}' AND Y eq '${odataQuote(String(req.y || ''))}' AND Z eq '${odataQuote(String(req.z || ''))}'` else if (req.role === 'return') { // return bins = locator type with all three IsAvailableFor* flags off — filter in Node const rows = await listOrgLocators(event, token, req.orgId, req.warehouseId || null) return rows.find((l) => mapLocator(l).isReturn) || null } if (!pred) return null const get = async (filter: string) => { const res: any = await fetchHelper(event, `models/m_locator?$filter=${enc(filter)}&$select=${SELECT}&$expand=${EXPAND}&$top=5`, 'GET', token, null) return res?.records?.[0] || null } let hit = req.warehouseId ? await get(`${pred} AND ${base} AND M_Warehouse_ID eq ${req.warehouseId}`) : null if (!hit) hit = await get(`${pred} AND ${base}`) // "AA - 01 - 01" scanned / typed with spaces → match by coordinates if (!hit && req.code && !req.role) { const xyz = parseXyz(req.code) if (xyz) hit = await get(`X eq '${odataQuote(xyz.x)}' AND Y eq '${odataQuote(xyz.y)}' AND Z eq '${odataQuote(xyz.z)}' AND ${base}`) } return hit } /** * Existing locator of the org for the code / coordinates / role, or a new one created * from the master template. `null` when neither the org nor the master org knows it. */ export const ensureLocator = async (event: any, token: string, req: EnsureRequest): Promise<{ locator: CatalogLocator; created: boolean } | null> => { const orgId = Number(req.orgId) || 0 if (!orgId) throw createError({ statusCode: 400, statusMessage: 'organizationId is required' }) const warehouseId = Number(req.warehouseId) || null const r: EnsureRequest = { ...req, orgId, warehouseId } const existing = await findInOrg(event, token, r) if (existing) return { locator: mapLocator(existing), created: false } if (orgId === masterOrgId()) return null const template = findMasterTemplate(await listMasterLocators(event, token), { code: r.code, x: r.x, y: r.y, z: r.z, role: r.role }) if (!template) return null const targetWarehouseId = warehouseId || await resolveWarehouseForOrg(event, token, orgId) if (!targetWarehouseId) throw createError({ statusCode: 409, statusMessage: 'Organization has no warehouse' }) // Lowercase-first scalars, PascalCase FK objects (iDempiere REST write casing). const payload: any = { AD_Org_ID: { id: orgId, tableName: 'AD_Org' }, M_Warehouse_ID: { id: targetWarehouseId, tableName: 'M_Warehouse' }, value: String(template.Value || ''), x: String(template.X ?? ''), y: String(template.Y ?? ''), z: String(template.Z ?? ''), priorityNo: Number(template.PriorityNo) || 50, isActive: true, // the org's first default locator (role 'default') becomes its receiving default isDefault: r.role === 'default' } const ltId = idOf(template.M_LocatorType_ID) if (ltId) payload.M_LocatorType_ID = { id: ltId, tableName: 'M_LocatorType' } let res: any try { res = await fetchHelper(event, 'models/m_locator', 'POST', token, payload) } catch (err: any) { // Lost a race with a parallel create → read it back const again = await findInOrg(event, token, r) if (again) return { locator: mapLocator(again), created: false } throw err } if (!res?.id) throw createError({ statusCode: 502, statusMessage: 'Locator could not be created' }) console.info(`[locator-catalog] created locator ${payload.value} for org ${orgId} (warehouse ${targetWarehouseId}) from master #${template.id}`) const created = mapLocator({ ...res, M_LocatorType_ID: template.M_LocatorType_ID }) created.isDefault = !!payload.isDefault return { locator: created, created: true } } /** Compact client shape shared by the lookup routes (keeps their old fields). */ export const lookupShape = (l: CatalogLocator, created = false) => ({ id: l.id, code: l.code, Value: l.code, Name: l.code, X: l.x, Y: l.y, Z: l.z, isDefault: l.isDefault, warehouseId: l.warehouseId, organizationId: l.organizationId, locatorTypeId: l.locatorTypeId, locatorType: l.locatorType, isShippable: l.isShippable, isReturn: l.isReturn, created })