首页 文章 精选 留言 我的

精选列表

搜索[智能解析],共10000篇文章
优秀的个人博客,低调大师

智能仓储 WMS 前端系统 | 从 WMS 到 ERP 的数据同步:前端状态一致性保障

# 引言 WMS 与 ERP 之间,从来不是"同步一下"那么简单。ERP 是系统的记录源(System of Record),库存、成本、账务以它为准;WMS 是执行系统,实时追踪每一托、每一箱、每一次移动。两者的时间尺度差着量级——ERP 可能按天关账,WMS 按秒变。于是前端永远面对一个尴尬局面:**WMS 显示"刚出库 200 件",ERP 还显示"在库",页面该信谁?** 本文不讲后端怎么同步,只讲前端这一侧:如何判断"我手上的数据还是不是真的"、乐观更新的回滚怎么不丢用户的改动、离线期间产生的操作怎么在恢复后不冲突、以及 CRDT 如何在多人同时盘点这种极端场景下真正避免覆盖。 --- # 一、先统一心智模型:三个时间戳 前端一致性的全部难题,都源于"同一份数据有多个版本、且生成时间不同"。解决它的第一步是给每个业务对象打上三个时间戳,而不是一个。 ```javascript // src/sync/clock.js export function createVersionedRecord(base = {}) { return { ...base, // 三个时间戳是这套模型的全部基石 version: base.version || 0, // 单调版本号,由服务端分配,全局唯一 updatedAt: base.updatedAt || Date.now(), // 服务端权威时间,用于时序比较 clientTime: base.clientTime || Date.now(), // 客户端墙钟,仅用于离线期间排序 origin: base.origin || 'wms', // 'wms' | 'erp' | 'client' actor: base.actor || null, // 操作人,用于冲突归因 }; } export function isNewer(a, b) { // 第一判据:服务端版本号(权威) if (a.version !== b.version) return a.version > b.version; // 第二判据:服务端时间(跨设备一致) if (a.updatedAt !== b.updatedAt) return a.updatedAt > b.updatedAt; // 第三判据:客户端时间(仅离线期间有意义) return a.clientTime > b.clientTime; } ``` 逐段解读: - `version`** 是权威**。它由服务端单调分配,跨设备、跨时区都可比。两个客户端各自改了同一份数据,谁先被服务端接受,谁的 `version` 就更大。这是唯一可靠的"谁赢了"依据。 - `updatedAt`** 是第二判据**。为什么需要它?因为乐观更新时客户端还没拿到服务端版本号。同时 `Date.now()` 在设备上不可信——有人调过系统时间,或者设备时钟快了三小时,仅靠墙钟排序会一团糟。服务端时间由 NTP 校准,是可靠的全局时钟。 - `clientTime`** 是第三判据,只在离线期间有意义**。两个离线操作发生在同一台设备,顺序由它决定。一旦到达服务端,就以 `version` 为准。 - `origin`** 标记数据来源**。这是回答"该信谁"的关键字段——ERP 来的数据标记为 `erp`,它在某些字段上是权威的(如标准成本、物料主数据)。 - `isNewer` 的比较顺序不可颠倒。版本号第一、服务端时间第二、客户端时间第三,这个优先级是整套一致性模型的全部逻辑。 ![](https://developer.qcloudimg.com/http-save/audit-10133498/2a1f4278a85e74bbf68d48c7b0ccc75c.png) --- # 二、权威源策略:不是"谁新信谁",而是"谁该管这个字段" 最容易犯的错是把同步做成简单的"后写覆盖先写"。这会出问题:WMS 刚把实际库存改成 800,ERP 因为网络延迟推送了一条旧快照说"还是 1000",如果按时间覆盖,WMS 辛苦维护的实时数据就被一个迟到消息抹掉了。 ```javascript // src/sync/authority.js // 字段级权威源:同一份记录里,不同字段由不同系统说了算 export const AUTHORITY = { 'stock.onHand': 'wms', // 实际在库量:WMS 权威(实时) 'stock.allocated': 'wms', // 已分配量:WMS 权威 'stock.available': 'wms', // 可用量:由 WMS 计算 'stock.standardCost': 'erp', // 标准成本:ERP 权威(财务) 'sku.description': 'erp', // 物料描述:ERP 权威 'sku.supplier': 'erp', // 供应商:ERP 权威 'location.code': 'wms', // 库位编码:WMS 权威 'location.lockReason':'wms', // 锁定原因:WMS 权威 'batch.expiryDate': 'erp', // 批次有效期:ERP 权威 }; export function mergeByAuthority(local, remote) { const next = { ...local }; for (const [key, remoteVal] of Object.entries(flatten(remote))) { const authority = AUTHORITY[key] || 'wms'; const source = remote.origin || 'wms'; if (authority === source) { // 来源即权威源:覆盖(前提是版本更新,见下方) if (isNewer(remote, local)) setPath(next, key, remoteVal); } // 来源不是权威源:丢弃,保留本地值 } next.version = Math.max(local.version, remote.version); return next; } function flatten(obj, prefix = '', out = {}) { for (const [k, v] of Object.entries(obj)) { const path = prefix ? `${prefix}.${k}` : k; if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, path, out); else out[path] = v; } return out; } function setPath(obj, path, value) { const keys = path.split('.'); const last = keys.pop(); let cur = obj; for (const k of keys) cur = cur[k] = cur[k] || {}; cur[last] = value; } ``` 逐段解读: - **字段级权威源,不是文档级**。这是全文最重要的一点。同一份库存记录里,实际数量由 WMS 管,标准成本由 ERP 管,有效期由 ERP 管。用一个"全局权威源"必然互相踩踏。 - `AUTHORITY` 表是**业务规则**,不是技术配置。它该由供应链与财务共同确认并写在文档里,前端只负责执行。改这张表要慎重——它决定了系统之间谁说了算。 - `authority === source` 的判断让迟到的 ERP 快照不会覆盖 WMS 的实时库存:ERP 推送的 `stock.onHand` 不是它的权威字段,前端直接丢弃。 - `isNewer` 仍要做版本校验,防止乱序消息。即使来源是权威源,一条陈旧消息也不该覆盖新数据。 - `version` 取 `max` 而不是用 remote 的。合并后的记录要保留最大版本号,否则会被后续以"版本更旧"为由拒绝。 --- # 三、乐观更新与回滚:敢先改,也要敢撤回 乐观更新是体验的核心——用户点了"确认拣货",界面立刻响应,不用等网络。但它带来一个问题:**如果服务端拒绝了,怎么回到原来的样子,同时不丢掉用户在这期间的其他改动?** ```javascript // src/sync/store.js import { create } from 'zustand'; import { createVersionedRecord, isNewer } from './clock'; export const useSyncStore = create((set, get) => ({ entities: new Map(), // 唯一可信源:业务对象 by id inflight: new Map(), // 进行中的变更:id → { base, patches, undo } conflicts: [], applyLocal(id, patches) { set((state) => { const cur = state.entities.get(id) || createVersionedRecord({ id }); const base = clone(cur); // 乐观更新:立即应用,同时记录 base 用于回滚 const optimistic = { ...cur, ...patches, clientTime: Date.now(), _pending: true }; const next = new Map(state.entities); next.set(id, optimistic); const inflight = new Map(state.inflight); inflight.set(id, { base, patches, undo: () => base }); return { entities: next, inflight }; }); }, // 服务端确认:用服务端版本号替换乐观版本,清除 pending confirm(id, serverRecord) { set((state) => { const next = new Map(state.entities); next.set(id, { ...serverRecord, _pending: false }); const inflight = new Map(state.inflight); inflight.delete(id); return { entities: next, inflight }; }); }, // 服务端拒绝:回滚到 base,并保留其他并发改动的 patches reject(id, reason) { set((state) => { const tx = state.inflight.get(id); if (!tx) return state; const next = new Map(state.entities); next.set(id, { ...tx.base, _pending: false, _lastError: reason }); const inflight = new Map(state.inflight); inflight.delete(id); return { entities: next, inflight }; }); }, // 接收服务端推送:仅在更新时才重渲染 receive(serverRecord) { set((state) => { const cur = state.entities.get(serverRecord.id); // 比本地还旧:忽略(防乱序) if (cur && !isNewer(serverRecord, cur)) return state; // 本地有未提交改动:进入冲突队列,不直接覆盖 if (cur?._pending) { return { conflicts: [...state.conflicts, { id: serverRecord.id, remote: serverRecord, local: cur }] }; } const next = new Map(state.entities); next.set(serverRecord.id, { ...serverRecord, _pending: false }); return { entities: next }; }); }, })); ``` 逐段解读: - `_pending`** 标志是乐观更新的视觉信号**。带这个标志的行显示"同步中"弱态,让用户感知到网络状态,也阻止他连续点击造成重复提交。 - `base` 快照让回滚精确。它不是"全量恢复",而是回到这个操作之前的状态——用户同时做的其他改动不会因此丢失。 - `reject` 时把 `reason` 存进记录。前端据此显示可读错误("库存不足""库位已被锁定"),而不是吐一个错误码。 - `receive` 里的**防乱序**是关键:服务端推送如果版本更旧,直接丢弃。网络分区后重连时服务端可能一次性补推一批历史消息,顺序不保证,这个判断防止旧数据覆盖新数据。 - **有 pending 改动时推来的更新进入冲突队列,不直接覆盖**。这是最容易被漏掉的场景——用户正在改,服务端也推了个新版本,谁覆盖谁都不对,必须进队列等用户决定。 ![](https://developer.qcloudimg.com/http-save/audit-10133498/7b9972305ae6ffea79d7a56a1f7dc6b7.png) --- # 四、离线队列:幂等键是灵魂 仓库里断网是常态,不是异常。离线期间产生的操作必须被记录、有序、且能安全地重放——**重放不能产生副作用**。 ```javascript // src/sync/outbox.js const DB_KEY = '@wms/outbox'; const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key'; export async function enqueue(operation) { const op = { id: crypto.randomUUID(), idempotencyKey: `wms-${operation.type}-${operation.entityId}-${operation.clientTime}`, type: operation.type, // 'pick.confirm' | 'stock.adjust' | 'location.transfer' entityId: operation.entityId, payload: operation.payload, createdAt: Date.now(), attempts: 0, status: 'queued', }; const box = await load(); box.push(op); await persist(box); return op; } export async function flushOutbox() { const box = await load(); for (const op of box) { if (op.status === 'done' || op.status === 'failed') continue; op.attempts++; try { const res = await fetch(`/api/wms/${op.type}`, { method: 'POST', headers: { 'Content-Type': 'application/json', [IDEMPOTENCY_KEY_HEADER]: op.idempotencyKey, }, body: JSON.stringify({ ...op.payload, clientTime: op.createdAt, version: op.version }), }); if (res.status === 409) { // 冲突:服务端有更新版本,转入冲突解决流程 op.status = 'conflict'; continue; } if (!res.ok) throw new Error(`HTTP ${res.status}`); op.status = 'done'; op.response = await res.json(); } catch (e) { if (op.attempts >= 5) op.status = 'failed'; } } await persist(box); } function load() { /* 从 localStorage / AsyncStorage 读取 */ } function persist(box) { /* 写入持久化存储 */ } ``` 逐段解读: - **幂等键的构造决定了它能否防重**。格式是 `wms-{type}-{entityId}-{clientTime}`——同一台设备对同一实体、同一时刻的同一操作,幂等键必然相同。服务端用它去重,即使网络重试也不会被处理两次。 - `crypto.randomUUID()` 生成 `id`,`idempotencyKey` 由业务字段派生。两者分工:前者是本地记录的唯一标识,后者是服务端的去重依据。 - `409 Conflict`** 单独处理**。它意味着服务端有更新版本,不是网络错误,重试只会一直 409。必须转入冲突解决流程。 - `attempts >= 5` 上限防止死循环。失败操作留在队列会阻塞后续操作、占用存储。 - **持久化存储必须用异步 API 串行写**。`localStorage.setItem` 同步且会阻塞主线程,离线期间频繁入队会卡顿;PDA 上更该用异步存储并加写入队列。 ![](https://developer.qcloudimg.com/http-save/audit-10133498/3586f6781cab961e197cc1727ab1aaed.png) --- # 五、CRDT:多人同时改同一字段怎么办 上面的方案在"一个实体一个最后写入者"的假设下工作良好。但仓库里有真正的并发冲突:**两个盘点员同时对同一排货架盘点**,ERP 和 WMS 同时推同一字段的不同值。最后写入者获胜(LWW)在这里会**丢失数据**。 ```javascript // src/sync/gcounter.js // G-Counter(增长计数器):支持多节点并发自增,可交换、可结合,永不冲突 export function createGCounter(nodeId) { const counts = new Map(); // nodeId -> 正整数 counts.set(nodeId, 0); return { increment() { counts.set(nodeId, (counts.get(nodeId) || 0) + 1); return this.value(); }, value() { let sum = 0; for (const v of counts.values()) sum += v; return sum; }, merge(other) { for (const [node, val] of other.counts) { const cur = counts.get(node) || 0; counts.set(node, Math.max(cur, val)); // 逐节点取最大值,可交换 } return this; }, serialize() { return { type: 'gcounter', nodeId, counts: [...counts.entries()] }; }, }; } // PN-Counter:可增可减的计数器,用两个 G-Counter 实现 export function createPNCounter(nodeId) { const p = createGCounter(nodeId); // 增量 const n = createGCounter(nodeId); // 减量 return { increment() { p.increment(); }, decrement() { n.increment(); }, value() { return p.value() - n.value(); }, merge(other) { p.merge(other.p); n.merge(other.n); }, }; } ``` 逐段解读: - **CRDT 的核心特性是可交换性(commutative)**。A 先合并 B,还是 B 先合并 A,结果一样。这就让网络分区、消息乱序、重复推送都不成问题——因为顺序不影响最终结果。 - **G-Counter 只能增不能减**,但它解决了盘点场景的关键需求:两个盘点员各报一个数,系统要**合并而不是覆盖**。`merge` 逐节点取最大值,两人的计数都保留。 - **PN-Counter = P 增 + N 减**。库存有进有出,需要它。值是 `P - N`,合并时两个 G-Counter 各自 merge。 - 实际生产中的 CRDT 库(如 Yjs、Automerge)远比这复杂,但**思路完全一致**:数据结构保证合并语义正确,不需要锁、不需要协商、不需要"谁先谁后"。 - 盘点场景真正需要的往往是 `Max-Value` 语义(取最新盘点数)或 `Multi-Value` 语义(保留所有读数待人工确认),CRDT 家族有不同的实现应对。 --- # 六、UI 一致性:让用户看见"状态" 一致性不是只有代码层面的事。用户必须知道当前是同步态、离线态还是冲突态,否则他会在断网时以为操作已经生效,造成真实业务损失。 ```javascript // src/sync/StatusBadge.jsx import React from 'react'; import { useSyncStore } from './store'; export function StatusBadge({ entityId }) { const record = useSyncStore((s) => s.entities.get(entityId)); const online = useOnlineStatus(); if (!online) return 离线 · 操作已暂存; if (record?._pending) return 同步中…; if (record?._lastError) return 同步失败 · 点击重试; if (record?._conflict) return 有冲突 · 需处理; return 已同步; } function Badge({ tone, children }) { return {children}; } ``` - **离线状态要提前感知**。`navigator.onLine` 只能告诉你有没有网络接口,不能告诉你 API 能不能通。更可靠的是基于最近几次请求的成功率推断,或靠 WebSocket 的 `readyState`。 - **四种状态必须有视觉区分**。离线、同步中、失败、冲突,含义完全不同,处理方式也不同。混成一个"加载中"会让用户无从判断。 - `role="status"` 让读屏用户能感知状态变化。 ![](https://developer.qcloudimg.com/http-save/audit-10133498/5606e12373daab00d60bf7afe2165349.png) --- # 七、可观测性:一致性问题必须能查 数据不一致是最难排查的 Bug——它不报错,只是数字不对。必须能追溯每一次状态变更。 - **每个状态变更记录来源、版本、时间戳、操作者**。出问题时能重放"这份数据是怎么变成这样的"。 - **监控版本倒退事件**。前端发现 `updatedAt` 倒退了,说明有系统在以陈旧数据覆盖新数据,这是集成故障的信号,必须告警而不是默默接受。 - **监控冲突率**。冲突率突然升高,往往意味着同步链路出了问题(如 ERP 批量回写、时区错乱)。 - **网络抖动时用指数退避重连**,但**业务操作的重试必须是幂等的**。不带幂等键的重试会把一个"确认拣货"变成两个。 --- # 结语 本文交付了一套从字段级权威源到 CRDT 的 WMS-ERP 前端一致性方案。五条核心结论: **第一,权威源是字段级的,不是文档级的。** 实际库存归 WMS、标准成本归 ERP、有效期归 ERP。用一个"全局权威源"必然互相踩踏,字段级策略是这套模型的全部前提。 **第二,乐观更新要敢改也要敢撤。** 记录 `base` 快照用于精确回滚,保留 `_pending` 作为视觉信号,拒绝时把可读错误存回记录。收到推送时若本地有未提交改动,进入冲突队列而不是直接覆盖。 **第三,离线队列的灵魂是幂等键。** 键由 `{type}-{entityId}-{clientTime}` 派生,服务端据此去重;`409` 单独处理转入冲突流程,重试只针对网络错误。 **第四,LWW 会丢失并发改动。** 两个盘点员同时盘点同一排货架时,必须按场景选 CRDT(计数器/集合)而非最后写入者获胜。CRDT 的可交换性让消息乱序、重复推送都不再是问题。 **第五,状态必须可见。** 离线、同步中、失败、冲突四种状态各有含义,要有视觉区分和读屏支持;版本倒退与冲突率要可监控。

优秀的个人博客,低调大师

智能仓储 WMS 前端系统 | AI 库位推荐引擎:3D 仓库布局与热力图实现

# 引言 库位推荐是 AI 进入 WMS 时最容易被"演示化"的一个环节:一张花哨的 3D 仓库图,几个发光的库位,然后——就没有然后了。真正上不了生产的原因通常不在算法,而在**前端把"得分"当成了"决策"**。得分只是输入,落地还要过库位尺寸、承重、先进先出、混放规则、锁定状态、通道可达性、温区约束这一连串硬门槛;任何一步被跳过,拣货员就会把一托贵重商品塞进最远的角落,或者让两个互斥 SKU 共享同一货位。 本文要交付的是一条可运行的链路:**离散货位点 → 高斯核密度场 → 归一化得分 → 规则剪枝 → 推荐排序 → 2D 热力图与 3D 货架同步着色 → 人工确认上架**。其中 2D 层负责"看趋势、找热区",3D 层负责"看结构、做决策",两者共用同一份得分数据,而不是各算各的。 --- # 一、数据模型:坐标系统一,才有可加和的场 所有热力可视化的第一个坑不是渲染,是**坐标系不统一**。库位编码 `A-03-02` 只在仓库内部有意义,Canvas 像素和 Three.js 世界坐标都读不懂它。所以要先建立一张 `worldMap`:货位码到世界坐标的映射。 ```javascript export const ZONE_ORIGIN = { x: -48, z: -30 }; // 仓库西南角在世界坐标中的位置 export const CELL_SIZE = { x: 2.4, z: 1.8 }; // 每个货位占用的世界空间(米) export function codeToWorld(code) { // 编码约定:A-03-02 → 区/排/列。生产环境建议由后端 /api/location/map 返回映射表 const [zone, row, col] = code.split('-'); const r = parseInt(row, 10) - 1; const c = parseInt(col, 10) - 1; const offset = ZONE_OFFSETS[zone] || { x: 0, z: 0 }; return { x: ZONE_ORIGIN.x + offset.x + c * CELL_SIZE.x + CELL_SIZE.x / 2, z: ZONE_ORIGIN.z + offset.z + r * CELL_SIZE.z + CELL_SIZE.z / 2, }; } export function worldToGrid(p, cols, cell) { // 世界坐标 → 网格索引,用于把连续场离散到格子 const gx = Math.floor((p.x - ZONE_ORIGIN.x) / cell.x); const gz = Math.floor((p.z - ZONE_ORIGIN.z) / cell.z); return { col: Math.max(0, Math.min(cols - 1, gx)), row: Math.max(0, gz) }; } ``` 要点:`codeToWorld` 是纯函数,不依赖 DOM 也不依赖 Three.js,因此同一份映射可以被 2D Canvas、3D 场景、路径动画、PDA 端共用。坐标系统一之后,"某货位的得分"才是一个可以在任何视图里复用的数据,而不是某个组件内部的局部状态。 --- # 二、得分生成:核密度估计把离散点变成连续场 仓库里没有"每个库位的历史访问次数"这种天然数据。有的是离散事件:拣货任务、最近出库、补货记录。要把它们变成连续的"热度场",用的是**核密度估计(KDE)**——每个事件点向外扩散成一个高斯核,所有核叠加成一张场。 算法流程是:分箱 → 双线性权重累加 → 二维高斯卷积 → 阈值切片。这正是 `d3-contour` 的 `contourDensity()` 在做的事:先通过双线性插值把点累加到网格(比"就近取整"平滑得多),再以指定带宽做高斯模糊得到连续密度面,最后用 marching squares 输出 GeoJSON MultiPolygon。 ```javascript export function buildDensityField(points, cols, rows, { cellSize = 2, bandwidth = 6 } = {}) { const { contourDensity } = await import('d3-contour'); // 动态导入,非 3D 场景不必加载 const size = [cols * cellSize, rows * cellSize]; const density = contourDensity() .x((d) => d.p.x) .y((d) => d.p.z) .weight((d) => d.weight) // 事件权重:出库 1.0,补货 0.6,拣货复核 0.8 .size(size) .cellSize(cellSize) // 越小越精细,但内存与算力同步上涨 .bandwidth(bandwidth) // 带宽决定"热区扩散半径" .thresholds(12); return density(points); // 返回 GeoJSON MultiPolygon 数组,每个 geometry.value 是一个阈值 } ``` 为什么用 KDE 而不是简单计数?因为库位是离散格子,但**作业热度是连续的**。只数格子会把相邻空位判成"完全没热过",而 KDE 让热度自然溢出到周边货位,这才是"附近还有空位"的语义。带宽的选择有实际含义:带宽太小,热区碎成一粒粒的孤立点,找不到成片区域;带宽太大,整个仓库都变红,区分度为零。现场调试按货架排距的 1.5 倍取值通常比较合理。 ![](https://developer.qcloudimg.com/http-save/audit-10133498/efd1c306654661f82ee883752e97c189.png) --- # 三、推荐核心:先打分,再用硬规则剪枝 得分生成后,真正的"推荐"发生在规则层。这里必须分清两类约束:**优化目标**(近出口、高周转率、通道可达)可以写进权重;**硬约束**(尺寸、承重、混放、温区、锁定)必须做成一票否决的过滤。把它们混在权重里,模型会在"更近一点"和"塞得下"之间做权衡,最终给出物理上不可行的方案。 ```javascript export function recommend(sku, field, locations, opts = {}) { const { preferNearExit = 0.35, // 距离出口的权重 preferHighTurnover = 0.30, preferZoneMatch = 0.20, // SKU 温区与货位温区匹配 preferEvenLoad = 0.15, // 货架承重均衡 } = opts; const candidates = locations .filter((loc) => loc.status === 'EMPTY') // 硬门槛:必须空 .filter((loc) => loc.maxWeight >= sku.weight) // 硬门槛:承重 .filter((loc) => loc.maxDim loc.zoneType === sku.zoneType) // 硬门槛:温区/危险品互斥 .filter((loc) => !loc.locked); // 硬门槛:未被盘点/移库锁定 const maxDist = Math.max(...candidates.map((c) => c.distanceToExit)); const scored = candidates.map((loc) => { const p = codeToWorld(loc.code); const f = sampleField(field, p); // 从连续场采样该点密度值 const turnScore = 1 - clamp(loc.idleDays / 60, 0, 1); const exitScore = 1 - loc.distanceToExit / maxDist; const zoneScore = loc.zoneType === sku.zoneType ? 1 : 0; const loadScore = 1 - clamp(loc.shelfLoad / loc.shelfCapacity, 0, 1); return { ...loc, score: f * 0.4 + turnScore * preferHighTurnover + exitScore * preferNearExit + zoneScore * preferZoneMatch + loadScore * preferEvenLoad, density: f, }; }); return scored.sort((a, b) => b.score - a.score).slice(0, 5); } ``` 逐段解读: - 五个 `filter` 的顺序有讲究——**先按 O(1) 字段过滤,再算距离**。库位总量常在上万级,先砍掉锁定、温区不匹配的,能显著减少后续几何计算。 - `sampleField` 从连续场采样而非回查原始事件。这保证 2D 热力图、3D 着色、排序用的是**同一个数学对象**,不会有"图上看着热、推荐却不选"的打架。 - `maxDist` 只从已通过硬门槛的集合里取最大值。如果先用全量库位算,一个远在天边的禁用库位会压缩所有有效分数的差距,让排序失去区分度。 - 权重总和不需要等于 1,归一化发生在排序阶段。权重用配置项而不是写死,是为了让运营按仓型调参:出库导向的配送中心提高 `preferNearExit`,存储导向的 DC 提高 `preferEvenLoad`。 ![](https://developer.qcloudimg.com/http-save/audit-10133498/ae46bc5d7e541d64bc500a892ec6d764.png) --- # 四、2D 热力图:预分配离屏 Canvas,绝不逐帧建渐变 2D 热力图适合总览与找趋势。核心工艺是:把每个货位画成一个**预渲染的灰度径向核**(中心 alpha 1、边缘 0),按得分缩放 `globalAlpha`,叠加到离屏灰度缓冲;再用 256 级 LUT 把灰度映射成彩色。 ```javascript export class Heatmap2D { constructor(canvas, { cols, rows, cell, lut } = {}) { this.canvas = canvas; this.ctx = canvas.getContext('2d', { willReadFrequently: false }); this.cols = cols; this.rows = rows; this.cell = cell; this.lut = lut || buildLUT(); // 256 级色彩查找表 this.kernel = this.buildKernel(26); // 预生成一次,常驻内存 this.off = document.createElement('canvas'); // 离屏灰度缓冲 this.off.width = cols * cell; this.off.height = rows * cell; this.offCtx = this.off.getContext('2d'); } buildKernel(radius) { const c = document.createElement('canvas'); c.width = c.height = radius * 2; const g = c.getContext('2d'); const grad = g.createRadialGradient(radius, radius, 0, radius, radius, radius); grad.addColorStop(0, 'rgba(0,0,0,1)'); grad.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = grad; g.fillRect(0, 0, radius * 2, radius * 2); return c; } draw(scores) { const { offCtx, cols, rows, cell } = this; offCtx.clearRect(0, 0, cols * cell, rows * cell); for (const s of scores) { const p = codeToWorld(s.code); const gx = (p.x - ZONE_ORIGIN.x) * (1 / cell); const gy = (p.z - ZONE_ORIGIN.z) * (1 / cell); const a = Math.pow(s.score, 0.8); // gamma 校正,拉开中低分区 offCtx.globalAlpha = a; offCtx.drawImage(this.kernel, gx - 13, gy - 13); } offCtx.globalAlpha = 1; const img = offCtx.getImageData(0, 0, cols * cell, rows * cell); const data = img.data; for (let i = 0; i AI 推荐上架位 {sku.code} · {sku.weight}kg {candidates.map((c, i) => ( {i === 0 ? '推荐' : i + 1} {c.code} 得分 {c.score.toFixed(2)} · 热度 {c.density.toFixed(2)} 距出口 {c.distanceToExit}m · 承重余量 {c.maxWeight - sku.weight}kg onConfirm(c)}>确认 ))} 推荐不合适?选择其他库位 onOverride(e.target.value)}> 选择库位… {/* 从通过硬门槛的候选集中取前 50 */} setReason(e.target.value)} /> ); } ``` 关键设计:**每个候选都展示得分的组成部分**(热度、距出口、承重余量),而不是只给一个总分。这解决的是信任问题——拣货员看到"距出口 42 米"就能判断这个推荐是否适合当前波次。覆盖原因强制填写,是后面模型迭代的训练数据来源。 --- # 七、性能分层与降级 仓库现场的设备差异极大,从带独显的调度台到老旧 PDA 都有。一张热力图不能只跑在一台机器上。 ```javascript // src/heatmap/adaptive.js export async function createHeatmap(canvas, scores) { const dpr = window.devicePixelRatio || 1; const memory = navigator.deviceMemory || 4; if (dpr = 4) { const Three = await import('../three/AisleView.js'); // 动态分包,3D 依赖不进首屏 return Three.createAisleView(canvas, { cols, rows }); } if (scores.length < 2000) { const C2D = await import('./Heatmap2D.js'); return new C2D.Heatmap2D(canvas, { cols, rows, cell: 4 }); } return await import('./HeatmapTiles.js'); // 瓦片降级:只渲染视口内格子 } ``` 分层逻辑按两个真实瓶颈决定:**显存/内存**(由 `deviceMemory` 粗估)和**像素填充率**(由 DPR 决定)。Three.js 相关代码必须动态 `import`,否则 3D 依赖会打进上架页的首屏包。 渲染层级的经验取舍: |场景|推荐方案|代价| | --- | --- | --- | |全场热力总览|2D Canvas + 256 级 LUT|首次回读较慢,之后纯查表| |单巷道结构查看|Three.js 顶点着色|显存占用,需限制 DPR| |单库位状态|DOM/CSS Grid 色块|万级节点需虚拟滚动| |老旧 PDA|服务端预渲染瓦片|实时性降低| `renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))` 这一行值得写进规范。Retina 屏的 3 倍 DPR 会让像素填充量变成 9 倍,而视觉收益几乎不可见。 --- # 结语 本文交付了一条从离散作业事件到可确认上架指令的完整链路。要点可以收成四条。 **第一,坐标系统是地基。**`codeToWorld` 把货位编码映射成世界坐标,2D 与 3D 共用这一份映射,得分才成为可复用的数据资产,而不是某个组件的私有状态。 **第二,密度场是连续场,不是格子计数。** 用高斯核密度估计把离散事件摊成连续面,热区才能自然溢出到周边空位;带宽决定"热"的扩散半径,是现场最需要调的参数。 **第三,硬约束必须一票否决,不能写进权重。** 尺寸、承重、温区、锁定先过滤,加权打分在后。混在一起,模型会给出一个物理上不可行的"最优解"。 **第四,两重视图各有分工。** 2D 看趋势、找热区,靠预生成径向核加 256 级 LUT 保住帧率;3D 看结构、做决策,靠顶点色随数据更新。LUT 预生成、kernel 只建一次、`needsUpdate` 显式置位,这三处决定了性能上限。

资源下载

更多资源
Mario

Mario

马里奥是站在游戏界顶峰的超人气多面角色。马里奥靠吃蘑菇成长,特征是大鼻子、头戴帽子、身穿背带裤,还留着胡子。与他的双胞胎兄弟路易基一起,长年担任任天堂的招牌角色。

腾讯云软件源

腾讯云软件源

为解决软件依赖安装时官方源访问速度慢的问题,腾讯云为一些软件搭建了缓存服务。您可以通过使用腾讯云软件源站来提升依赖包的安装速度。为了方便用户自由搭建服务架构,目前腾讯云软件源站支持公网访问和内网访问。

Nacos

Nacos

Nacos /nɑ:kəʊs/ 是 Dynamic Naming and Configuration Service 的首字母简称,一个易于构建 AI Agent 应用的动态服务发现、配置管理和AI智能体管理平台。Nacos 致力于帮助您发现、配置和管理微服务及AI智能体应用。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据、流量管理。Nacos 帮助您更敏捷和容易地构建、交付和管理微服务平台。

Spring

Spring

Spring框架(Spring Framework)是由Rod Johnson于2002年提出的开源Java企业级应用框架,旨在通过使用JavaBean替代传统EJB实现方式降低企业级编程开发的复杂性。该框架基于简单性、可测试性和松耦合性设计理念,提供核心容器、应用上下文、数据访问集成等模块,支持整合Hibernate、Struts等第三方框架,其适用范围不仅限于服务器端开发,绝大多数Java应用均可从中受益。

用户登录
用户注册