/** * Warehouse Builder — server helpers shared by the warehouse-layouts routes. * * - CUST_WarehouseLayout rows (one per warehouse + floor; the plan lives in LayoutJSON) * - paged M_Locator loader for a warehouse incl. the layout columns, with a fallback * when the columns do not exist yet (`columnsMissing`), 30 s cache + single-flight * - capability probes (table / columns present?) so the UI can show a DDL checklist * * Writes follow idempiere/write-casing-and-flags.md: scalar keys lowercase-first * (`layoutJSON`, `layoutPosX`, `pickSeqNo`), FK objects PascalCase `{ id, tableName }`. */ import { string } from 'alga-js' import fetchHelper from './fetchHelper' import { parseLayout } from '../../app/utils/warehouseLayout/schema' import type { WbLayout } from '../../app/utils/warehouseLayout/schema' import { mapLocatorRow } from '../../app/utils/warehouseLayout/wiring' import type { WbLocatorRow } from '../../app/utils/warehouseLayout/wiring' export const LAYOUT_MODEL = 'models/cust_warehouselayout' export const LAYOUT_TABLE = 'CUST_WarehouseLayout' export const LOCATOR_LAYOUT_COLUMNS = ['LayoutPosX', 'LayoutPosY', 'LayoutSide', 'PickSeqNo', 'LayoutObjectKey', 'LayoutZone', 'CUST_WarehouseLayout_ID'] const LOCATOR_BASE_SELECT = 'Value,X,Y,Z,PriorityNo,IsDefault,IsActive,M_LocatorType_ID,M_Warehouse_ID,AD_Org_ID' const LAYOUT_SELECT = 'Name,FloorNo,GridWidth,GridHeight,LayoutVersion,AppliedVersion,IsDefault,Description,Updated,M_Warehouse_ID,AD_Org_ID' const PAGE = 1000 const MAX_PAGES = 30 const LOCATOR_TTL_MS = 30 * 1000 const CAP_TTL_MS = 60 * 1000 export const enc = (v: string) => string.urlEncode(v) export const errStatus = (err: any): number => Number(err?.response?.status ?? err?.status ?? err?.statusCode ?? err?.data?.status ?? 0) || 0 export const errMessage = (err: any): string => String(err?.data?.detail ?? err?.data?.message ?? err?.data?.title ?? err?.message ?? err?.statusMessage ?? err ?? '') /** iDempiere's way of saying "that column is not in the dictionary (yet)". */ export const isUnknownColumnError = (err: any): boolean => { const m = errMessage(err).toLowerCase() return /column|property|field|select/.test(m) && /not exist|does not|invalid|unknown|not found|no such/.test(m) } /** Table not in the dictionary / REST model not found. */ export const isMissingTableError = (err: any): boolean => { const m = errMessage(err).toLowerCase() const st = errStatus(err) return (st === 404 || st === 400 || st === 500) && /table|model|not found|does not exist|invalid|unknown/.test(m) } 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 labelOf = (v: any): string => (v && typeof v === 'object' ? String(v.identifier ?? v.id ?? '') : String(v ?? '')) export interface LayoutRow { id: number warehouseId: number | null warehouseName: string organizationId: number | null name: string floorNo: number isDefault: boolean gridWidth: number gridHeight: number layoutVersion: number appliedVersion: number description: string updated: string layout?: WbLayout | null parseError?: string } export const mapLayoutRow = (r: any, withJson = false): LayoutRow => { const row: LayoutRow = { id: Number(r.id), warehouseId: idOf(r.M_Warehouse_ID), warehouseName: labelOf(r.M_Warehouse_ID), organizationId: idOf(r.AD_Org_ID), name: String(r.Name ?? r.name ?? ''), floorNo: Number(r.FloorNo ?? r.floorNo ?? 0) || 0, isDefault: isY(r.IsDefault ?? r.isDefault), gridWidth: Number(r.GridWidth ?? r.gridWidth ?? 40) || 40, gridHeight: Number(r.GridHeight ?? r.gridHeight ?? 25) || 25, layoutVersion: Number(r.LayoutVersion ?? r.layoutVersion ?? 1) || 1, appliedVersion: Number(r.AppliedVersion ?? r.appliedVersion ?? 0) || 0, description: String(r.Description ?? r.description ?? ''), updated: String(r.Updated ?? r.updated ?? '') } if (withJson) { const p = parseLayout(r.LayoutJSON ?? r.layoutJSON ?? r.layoutjson ?? '') row.layout = p.layout if (p.error) row.parseError = p.error } return row } export const listLayouts = async (event: any, token: string, warehouseId: number): Promise => { const res: any = await fetchHelper(event, `${LAYOUT_MODEL}?$filter=${enc(`M_Warehouse_ID eq ${warehouseId} AND IsActive eq true`)}&$select=${LAYOUT_SELECT}&$orderby=${enc('FloorNo asc')}&$top=50`, 'GET', token, null) return (res?.records || []).map((r: any) => mapLayoutRow(r)) } export const getLayout = async (event: any, token: string, id: number): Promise => { const res: any = await fetchHelper(event, `${LAYOUT_MODEL}/${id}`, 'GET', token, null) return res?.id ? mapLayoutRow(res, true) : null } export const warehouseOrg = async (event: any, token: string, warehouseId: number): Promise<{ orgId: number | null; name: string }> => { const res: any = await fetchHelper(event, `models/m_warehouse/${warehouseId}?$select=Name,AD_Org_ID`, 'GET', token, null) return { orgId: idOf(res?.AD_Org_ID), name: String(res?.Name || '') } } // ---- locators ----------------------------------------------------------------------------------- export interface LocatorLoad { records: WbLocatorRow[]; total: number; columnsMissing: boolean; missingColumns: string[] } const locatorCache = new Map() const inflight = new Map>() export const bustLocatorCache = (warehouseId?: number) => { if (warehouseId) locatorCache.delete(Number(warehouseId)); else locatorCache.clear() } const fetchPages = async (event: any, token: string, warehouseId: number, select: string): Promise => { const out: any[] = [] for (let page = 0; page < MAX_PAGES; page++) { const res: any = await fetchHelper(event, `models/m_locator?$filter=${enc(`M_Warehouse_ID eq ${warehouseId}`)}&$select=${select}&$orderby=${enc('M_Locator_ID asc')}&$top=${PAGE}&$skip=${page * PAGE}`, 'GET', token, null) const rows = res?.records || [] out.push(...rows) if (rows.length < PAGE) break } return out } /** All locators of a warehouse (active + inactive) with the layout columns when they exist. */ export const loadWarehouseLocators = async (event: any, token: string, warehouseId: number, fresh = false): Promise => { const wid = Number(warehouseId) if (!wid) return { records: [], total: 0, columnsMissing: false, missingColumns: [] } const hit = locatorCache.get(wid) if (!fresh && hit && Date.now() - hit.at < LOCATOR_TTL_MS) return hit.data if (inflight.has(wid)) return inflight.get(wid)! const p = (async () => { let columnsMissing = false let missingColumns: string[] = [] let rows: any[] try { rows = await fetchPages(event, token, wid, `${LOCATOR_BASE_SELECT},${LOCATOR_LAYOUT_COLUMNS.join(',')}`) } catch (err: any) { if (!isUnknownColumnError(err)) throw err columnsMissing = true missingColumns = [...LOCATOR_LAYOUT_COLUMNS] rows = await fetchPages(event, token, wid, LOCATOR_BASE_SELECT) } const data: LocatorLoad = { records: rows.map(mapLocatorRow), total: rows.length, columnsMissing, missingColumns } locatorCache.set(wid, { at: Date.now(), data }) return data })() inflight.set(wid, p) try { return await p } finally { inflight.delete(wid) } } /** Active locators in ALL warehouses whose Value is one of `values` (one physical hall mirrored per org). */ export const loadLocatorsByValues = async (event: any, token: string, values: string[]): Promise => { const { odataQuote } = await import('./odataHelper') const uniq = [...new Set(values.map(v => String(v || '').trim()).filter(Boolean))] const out: WbLocatorRow[] = [] for (let i = 0; i < uniq.length; i += 40) { const chunk = uniq.slice(i, i + 40) const filter = '(' + chunk.map(v => `Value eq '${odataQuote(v)}'`).join(' OR ') + ') AND IsActive eq true' for (let page = 0; page < 10; page++) { const res: any = await fetchHelper(event, `models/m_locator?$filter=${enc(filter)}&$select=${LOCATOR_BASE_SELECT},${LOCATOR_LAYOUT_COLUMNS.join(',')}&$orderby=${enc('M_Locator_ID asc')}&$top=${PAGE}&$skip=${page * PAGE}`, 'GET', token, null) const rows = res?.records || [] out.push(...rows.map(mapLocatorRow)) if (rows.length < PAGE) break } } return out } /** Locators (any warehouse) currently linked to a layout row — the set a re-apply may have to clear. */ export const loadLocatorsOfLayout = async (event: any, token: string, layoutId: number): Promise => { const out: WbLocatorRow[] = [] for (let page = 0; page < 20; page++) { const res: any = await fetchHelper(event, `models/m_locator?$filter=${enc(`CUST_WarehouseLayout_ID eq ${layoutId}`)}&$select=${LOCATOR_BASE_SELECT},${LOCATOR_LAYOUT_COLUMNS.join(',')}&$orderby=${enc('M_Locator_ID asc')}&$top=${PAGE}&$skip=${page * PAGE}`, 'GET', token, null) const rows = res?.records || [] out.push(...rows.map(mapLocatorRow)) if (rows.length < PAGE) break } return out } // ---- capabilities --------------------------------------------------------------------------------- export interface Capabilities { layoutTable: boolean locatorColumns: boolean missingColumns: string[] commissionWarehouseColumn: boolean checkedAt: string messages: Record } let capCache: { at: number; data: Capabilities } | null = null export const probeCapabilities = async (event: any, token: string, fresh = false): Promise => { if (!fresh && capCache && Date.now() - capCache.at < CAP_TTL_MS) return capCache.data const messages: Record = {} const tryGet = async (url: string): Promise => { try { await fetchHelper(event, url, 'GET', token, null); return true } catch (err: any) { const st = errStatus(err) if (st === 401 || st === 403) throw err messages[url] = errMessage(err).slice(0, 300) return false } } const layoutTable = await tryGet(`${LAYOUT_MODEL}?$select=Name&$top=1`) let locatorColumns = await tryGet(`models/m_locator?$select=${LOCATOR_LAYOUT_COLUMNS.join(',')}&$top=1`) const missingColumns: string[] = [] if (!locatorColumns) { for (const c of LOCATOR_LAYOUT_COLUMNS) if (!(await tryGet(`models/m_locator?$select=${c}&$top=1`))) missingColumns.push(c) locatorColumns = missingColumns.length === 0 } const commissionWarehouseColumn = await tryGet('models/cust_commissiontable?$select=M_Warehouse_ID&$top=1') const data: Capabilities = { layoutTable, locatorColumns, missingColumns, commissionWarehouseColumn, checkedAt: new Date().toISOString(), messages } capCache = { at: Date.now(), data } return data } export const bustCapabilities = () => { capCache = null } export const userNameFromCookie = (event: any): string => { try { const u = JSON.parse(getCookie(event, 'logship_user') || 'null'); return String(u?.name || u?.Name || u?.userName || '') } catch { return '' } }