/** * Warehouse Builder — wiring of map objects to M_Locator rows. * * Shelf bay × level → locator code `${X}-${Y}-${Z}` (X = rack code, Y = bay code, Z = level) — * the warehouse-wide convention (locatorCatalog.parseXyz). Pallet place → one locator id. * Statuses tell the Inspector / wiring modal what Apply would do. Also hosts the import * wizard's auto-layout (existing locators → facing rack pairs). DOM-free. */ import type { WbLayout, WbObject, WbPallet, WbShelf } from './schema' import { createLayout, createObject, snap, uid } from './schema' import type { WbBay } from './geometry' export interface WbLocatorRow { id: number value: string x: string y: string z: string priorityNo: number isDefault: boolean isActive: boolean locatorTypeId: number | null locatorType?: string layoutPosX?: number | null layoutPosY?: number | null layoutSide?: string | null pickSeqNo?: number | null layoutObjectKey?: string | null layoutZone?: string | null layoutId?: number | null } export const norm = (v: any): string => String(v ?? '').trim().toLowerCase().replace(/\s+/g, '') export const xyzKey = (x: any, y: any, z: any): string => `${norm(x)}|${norm(y)}|${norm(z)}` export const locatorCode = (x: string, y: string, z: string): string => `${x}-${y}-${z}` /** Tolerant mapper from an iDempiere m_locator record (any casing). */ export function mapLocatorRow (r: any): WbLocatorRow { const idOf = (v: any) => (v && typeof v === 'object' ? Number(v.id) : Number(v)) || null const numOrNull = (v: any) => (v === undefined || v === null || v === '' ? null : Number(v)) const isY = (v: any) => v === true || v === 'Y' return { id: Number(r.id), value: String(r.Value ?? r.value ?? ''), x: String(r.X ?? r.x ?? ''), y: String(r.Y ?? r.y ?? ''), z: String(r.Z ?? r.z ?? ''), priorityNo: Number(r.PriorityNo ?? r.priorityNo ?? 50) || 50, isDefault: isY(r.IsDefault ?? r.isDefault), isActive: (r.IsActive ?? r.isActive) === undefined ? true : isY(r.IsActive ?? r.isActive), locatorTypeId: idOf(r.M_LocatorType_ID), locatorType: r.M_LocatorType_ID && typeof r.M_LocatorType_ID === 'object' ? String(r.M_LocatorType_ID.identifier || '') : '', layoutPosX: numOrNull(r.LayoutPosX ?? r.layoutPosX), layoutPosY: numOrNull(r.LayoutPosY ?? r.layoutPosY), layoutSide: (r.LayoutSide ?? r.layoutSide) || null, pickSeqNo: numOrNull(r.PickSeqNo ?? r.pickSeqNo), layoutObjectKey: (r.LayoutObjectKey ?? r.layoutObjectKey) || null, layoutZone: (r.LayoutZone ?? r.layoutZone) || null, layoutId: idOf(r.CUST_WarehouseLayout_ID) } } export interface LocatorIndex { byXyz: Map byValue: Map byId: Map } export function buildLocatorIndex (rows: WbLocatorRow[]): LocatorIndex { const byXyz = new Map(), byValue = new Map(), byId = new Map() for (const r of rows) { byId.set(r.id, r) if (r.value && !byValue.has(norm(r.value))) byValue.set(norm(r.value), r) if (r.x || r.y || r.z) { const k = xyzKey(r.x, r.y, r.z); if (!byXyz.has(k)) byXyz.set(k, r) } } return { byXyz, byValue, byId } } export const findLocator = (index: LocatorIndex, x: string, y: string, z: string): WbLocatorRow | null => index.byXyz.get(xyzKey(x, y, z)) || index.byValue.get(norm(locatorCode(x, y, z))) || null export type WiringStatus = 'exists-free' | 'exists-mapped-here' | 'exists-mapped-elsewhere' | 'missing' | 'conflict' export interface WbCodeEntry { code: string x: string; y: string; z: string objectKey: string bayKey: string bayIndex: number levelIdx: number locatorId: number | null status: WiringStatus otherKey?: string | null } export interface CodeIndex { entries: WbCodeEntry[] byCode: Map byBay: Map byObject: Map duplicates: string[] mappedLocatorIds: Set counts: Record } /** Every (bay, level) of the layout resolved against the warehouse locators. */ export function computeCodeIndex (layout: WbLayout, bays: WbBay[], index: LocatorIndex): CodeIndex { const objects = new Map(layout.objects.map(o => [o.key, o])) const entries: WbCodeEntry[] = [] const seen = new Map() const dup = new Set() for (const b of bays) { const o = objects.get(b.objectKey) if (!o) continue if (o.type === 'shelf') { if (b.skip) continue const s = o as WbShelf b.levels.forEach((z, levelIdx) => { const code = locatorCode(s.rackCode, b.code, z) const e: WbCodeEntry = { code, x: s.rackCode, y: b.code, z, objectKey: s.key, bayKey: b.key, bayIndex: b.index, levelIdx, locatorId: null, status: 'missing' } const k = norm(code) if (seen.has(k)) { dup.add(code); e.status = 'conflict'; const first = seen.get(k)!; first.status = 'conflict' } else seen.set(k, e) entries.push(e) }) } else if (o.type === 'pallet') { const p = o as WbPallet const row = p.locatorId ? index.byId.get(Number(p.locatorId)) : null const code = row?.value || p.locatorCode || '' const e: WbCodeEntry = { code, x: row?.x || '', y: row?.y || '', z: row?.z || '', objectKey: p.key, bayKey: b.key, bayIndex: 0, levelIdx: 0, locatorId: row?.id ?? null, status: row ? 'exists-free' : 'missing' } if (row) { const k = 'id:' + row.id; if (seen.has(k)) { e.status = 'conflict'; seen.get(k)!.status = 'conflict'; dup.add(code) } else seen.set(k, e) } entries.push(e) } } const mapped = new Set() for (const e of entries) { if (e.status === 'conflict') continue const row = e.locatorId ? index.byId.get(e.locatorId) : findLocator(index, e.x, e.y, e.z) if (!row) { e.status = 'missing'; continue } e.locatorId = row.id if (mapped.has(row.id)) { e.status = 'conflict'; dup.add(e.code); continue } mapped.add(row.id) const ok = row.layoutObjectKey if (!ok || ok === e.objectKey) e.status = ok ? 'exists-mapped-here' : 'exists-free' else { e.status = 'exists-mapped-elsewhere'; e.otherKey = ok } } const byCode = new Map(), byBay = new Map(), byObject = new Map() const counts: Record = { 'exists-free': 0, 'exists-mapped-here': 0, 'exists-mapped-elsewhere': 0, missing: 0, conflict: 0 } for (const e of entries) { counts[e.status]++ if (e.code) byCode.set(norm(e.code), e) if (!byBay.has(e.bayKey)) byBay.set(e.bayKey, []) byBay.get(e.bayKey)!.push(e) if (!byObject.has(e.objectKey)) byObject.set(e.objectKey, []) byObject.get(e.objectKey)!.push(e) } return { entries, byCode, byBay, byObject, duplicates: [...dup], mappedLocatorIds: mapped, counts } } /** Warehouse locators that no object of this layout produces. */ export const unmappedLocators = (rows: WbLocatorRow[], ci: CodeIndex): WbLocatorRow[] => rows.filter(r => !ci.mappedLocatorIds.has(r.id)) /** Overall bay status for colouring (worst wins). */ export function bayStatus (entries: WbCodeEntry[] | undefined): WiringStatus | 'empty' { if (!entries || !entries.length) return 'empty' const order: WiringStatus[] = ['conflict', 'missing', 'exists-mapped-elsewhere', 'exists-free', 'exists-mapped-here'] for (const s of order) if (entries.some(e => e.status === s)) return s return 'exists-free' } export const naturalCompare = (a: string, b: string): number => a.localeCompare(b, 'de', { numeric: true, sensitivity: 'base' }) // ---- import wizard ---------------------------------------------------------------------------- export interface AutoLayoutOptions { aisleGap?: number // metres between facing racks (default 3) backGap?: number // metres between back-to-back racks (default 0.5) bayWidth?: number // default 1 depth?: number // default 1 margin?: number // default 2 existingRackCodes?: Set } /** Racks (X) → shelves laid out as facing pairs, bays = distinct Y, levels = distinct Z. */ export function autoLayoutFromLocators (rows: WbLocatorRow[], opts: AutoLayoutOptions = {}): { objects: WbShelf[]; grid: { width: number; height: number }; skipped: WbLocatorRow[] } { const aisleGap = opts.aisleGap ?? 3, backGap = opts.backGap ?? 0.5, bayW = opts.bayWidth ?? 1, depth = opts.depth ?? 1, margin = opts.margin ?? 2 const racks = new Map; zs: Set }>() const skipped: WbLocatorRow[] = [] for (const r of rows) { if (!r.x || !r.y || !r.z) { skipped.push(r); continue } if (opts.existingRackCodes?.has(r.x.toUpperCase())) continue if (!racks.has(r.x)) racks.set(r.x, { ys: new Set(), zs: new Set() }) racks.get(r.x)!.ys.add(r.y); racks.get(r.x)!.zs.add(r.z) } const codes = [...racks.keys()].sort(naturalCompare) const objects: WbShelf[] = [] let y = margin, maxLen = 0 codes.forEach((code, i) => { const rack = racks.get(code)! const ys = [...rack.ys].sort(naturalCompare) const zs = [...rack.zs].sort(naturalCompare) const w = snap(Math.max(bayW, ys.length * bayW)) maxLen = Math.max(maxLen, w) const pairIdx = Math.floor(i / 2), second = i % 2 === 1 // pair k: rack A faces down at yA, rack B faces up directly below the aisle const yA = margin + pairIdx * (2 * depth + aisleGap + backGap) const yy = second ? yA + depth + aisleGap : yA const contiguous = ys.every((v, k) => k === 0 || (/^\d+$/.test(v) && /^\d+$/.test(ys[k - 1]) && parseInt(v, 10) === parseInt(ys[k - 1], 10) + 1)) const numeric = ys.every(v => /^\d+$/.test(v)) objects.push({ key: uid(), type: 'shelf', x: margin, y: snap(yy), w, d: depth, rotation: 0, rackCode: code, bays: { count: ys.length, start: ys[0], step: 1, pad: numeric ? ys[0].length : 2, direction: 'asc', ...(contiguous && numeric ? {} : { codes: ys }) }, levels: zs, accessSide: second ? 'back' : 'front' }) y = yy + depth }) const width = Math.max(10, Math.ceil(maxLen + 2 * margin)) const height = Math.max(10, Math.ceil(y + margin)) return { objects, grid: { width, height }, skipped } } /** Convenience: new layout from existing locators (empty 40×25 when there are none). */ export function layoutFromLocators (rows: WbLocatorRow[]): WbLayout { if (!rows.length) return createLayout() const r = autoLayoutFromLocators(rows) const layout = createLayout({ grid: r.grid, objects: r.objects }) // outer walls around the hall layout.walls = [[0, 0, r.grid.width, 0], [r.grid.width, 0, r.grid.width, r.grid.height], [0, r.grid.height, r.grid.width, r.grid.height], [0, 0, 0, r.grid.height]] return layout } export { createObject }