SpringBoot+Vue3 仓储管理系统(WMS)设计:商品·SKU·出入库·移库·盘点全流程拆解
SpringBoot+Vue3 仓储管理系统(WMS)设计:商品·SKU·出入库·移库·盘点全流程拆解
🌐 演示地址:http://ruoyioffice.com | 📦 源码1·GitHub:ruoyi-office | 📦 源码2·GitCode:ruoyi-office | 📦 源码3·Gitee:ruoyi-office | 💬 微信:17156169080(备注「RuoYi Office」)
进销存里的库存只回答"还剩多少",而仓储管理系统(WMS,Warehouse Management System)要回答更细的问题:哪个 SKU 在哪个仓库、出入库怎么走单、跨仓移库怎么记、盘点对不上账怎么调。WMS 是物流仓储的执行层,并发尤其严苛——多个出库单可能同时扣同一个 SKU 的库存。RuoYi Office 的仓储模块(
yudao-module-wms)以"商品-SKU"主数据 + "SKU × 仓库"库存余额为核心,用入库单、出库单、移库单、盘点单四类单据驱动库存,单据走草稿→完成→作废,完成时才真正写库存;库存变更用悲观锁SELECT ... FOR UPDATE批量锁定后在内存计算校验,从根上杜绝并发超卖,每一次变动都落wms_inventory_history流水可追溯。
▲ 全景图:商品-SKU 主数据 + 仓库档案 → 入库/出库/移库/盘点四类单据 → "SKU×仓库"库存余额(悲观锁变更)→ wms_inventory_history 流水贯穿全程
引言:仓储管理(WMS)到底难在哪?
“仓库管理不就是记一下进出吗?和进销存有啥区别?”——这是个常见误解。WMS 的复杂度来自"颗粒度更细"和"并发更猛":
库存颗粒度到 SKU:同一个商品有不同规格(颜色、尺码、批次),WMS 的库存维度是"SKU × 仓库",而不是粗粒度的"商品"。一件衬衫,红色 L 码和蓝色 M 码是两个 SKU、两条库存。
单据有完整生命周期:仓库单据不是"建好就生效",而是草稿(待作业)→ 完成(已作业)→ 作废。只有"完成"时才真正写库存,草稿阶段可以反复改、可以删,作废后不影响库存。
并发是常态,超卖是灾难:WMS 往往对接电商订单、产线领料,同一个 SKU 可能被多个出库单同时扣减。如果库存扣减没做好并发控制,超卖几乎必然发生。
盘点要防"边盘边变":盘点时录入"实盘数量",但如果这期间别人又出入了库,账面就变了,直接覆盖会把别人的变动冲掉。盘点必须校验"账面快照有没有被改过"。
本文以 RuoYi Office 的 yudao-module-wms 模块为例,基于真实源码,完整拆解商品-SKU 主数据、四类单据、库存悲观锁变更、盘点快照校验、库存流水的设计与实现。
一、业务设计:以"SKU×仓库"库存为核心的仓储模型
1.1 核心抽象:库存维度是 SKU × 仓库
结论先行:WMS 的库存余额表 wms_inventory 以 (sku_id, warehouse_id) 为维度,记录每个 SKU 在每个仓库的当前数量。 商品(wms_item)是聚合概念,真正有库存、能出入库的是 SKU(wms_item_sku)——它带条码、规格、尺寸重量、成本价/销售价。这种"商品-SKU 两层"的主数据结构,是 WMS 能精细管理多规格商品的基础。
1.2 四类单据:入库、出库、移库、盘点
仓库的全部作业都收敛到四类单据,每类对应一种库存变更模式:
入库单 wms_receipt_order:库存 +(采购到货、退货入库、其它入库)
出库单 wms_shipment_order:库存 −(销售出库、领料出库、其它出库)
移库单 wms_movement_order:A 仓 − + B 仓 +(仓间转移,总量不变)
盘点单 wms_check_order:按实盘数量调平账面(盘盈 + / 盘亏 −)
每类单据都有主表 + 明细(detail),明细记录"哪个 SKU、哪个仓库、多少数量、单价"。
1.3 单据生命周期:草稿 → 完成 → 作废
仓库单据不是"建好即生效",而是有明确的状态机 WmsOrderStatusEnum:
| 状态 | 枚举 | 含义 | 是否影响库存 |
|---|---|---|---|
| 草稿 | PREPARE |
待作业,可改可删 | 否 |
| 完成 | FINISHED |
已作业,写入库存 | 是 |
| 作废 | CANCELED |
已取消 | 否 |
关键设计:只有"完成(complete)"动作才真正写库存。草稿阶段单据可以反复编辑、删除;完成后库存生效;作废只能在草稿态进行。状态流转用 updateByIdAndStatus(id, 旧状态, 新状态) 乐观校验,防止并发把同一张单据完成两次。
二、系统设计:模块组成与核心决策
2.1 模块组成
WMS 模块由"主数据 + 库存 + 四类单据"组成:
| 子模块 | 数据表 | 功能 |
|---|---|---|
| 仓库 | wms_warehouse |
仓库档案(编号/名称/排序) |
| 往来单位 | wms_merchant |
供应商/客户等往来单位 |
| 商品 / SKU | wms_item / wms_item_sku |
商品聚合 / 可入出库的规格单元 |
| 商品分类 / 品牌 | wms_item_category / wms_item_brand |
商品维度配置 |
| 库存余额 | wms_inventory |
SKU×仓库当前数量(真相源) |
| 库存流水 | wms_inventory_history |
每次变动一条流水,可追溯 |
| 入库单 | wms_receipt_order(+detail) |
完成后库存 + |
| 出库单 | wms_shipment_order(+detail) |
完成后库存 − |
| 移库单 | wms_movement_order(+detail) |
A 仓 − / B 仓 +,总量不变 |
| 盘点单 | wms_check_order(+detail) |
按实盘数量调平账面 |
2.2 核心设计决策
| 决策点 | 方案 | 理由 |
|---|---|---|
| 库存维度 | (sku_id, warehouse_id) |
精细到 SKU、支持多仓 |
| 库存写入时机 | 仅"完成"动作写库存 | 草稿可改可删,作业才生效 |
| 并发控制 | 悲观锁 SELECT ... FOR UPDATE 批量锁定 |
多单同扣 SKU 不超卖 |
| 库存计算 | 锁定后内存批量计算+校验,再批量更新 | 一次锁、一致更新 |
| 库存行缺失 | 不存在则创建,唯一索引冲突回查 | 高并发下补行安全 |
| 盘点防覆盖 | 校验账面快照未被改过 | 边盘边变不冲掉别人变动 |
| 全程留痕 | wms_inventory_history 流水 |
每次变动可追溯 |
三、PC 端功能实现
3.1 库存查询
库存页按"SKU × 仓库"展示当前数量,是仓库的实时全局视图。
▲ 库存列表:每行是一个"SKU × 仓库"的当前余额,数量由四类单据完成时变更,可下钻到库存流水查看每一次出入
设计要点:
- SKU 级精度:同一商品不同规格分别记库存,库存维度是 SKU 而非商品。
- 多仓并行:同一 SKU 在不同仓库各有一行库存,支持跨仓查询与移库。
- 只读不可手改:库存数量不能直接编辑,只能通过四类单据变更。
3.2 入库单列表
入库单是"货进仓"的单据,草稿态可编辑,完成时给对应仓库加库存。
▲ 入库单列表:状态分草稿/完成/作废,只有点"完成"才真正写库存并落入库流水;草稿态可改可删
设计要点:
- 完成才生效:草稿态不动库存,"完成"动作才把明细数量加进库存。
- 金额自动汇总:总数量、总金额由明细行自动汇总(明细可直接填行金额或按单价×数量算)。
- 类型可配:入库类型(采购入库/退货入库/其它入库)用字典配置。
3.3 盘点单列表
盘点单用于"账实对账",录入实盘数量后按盈亏调平账面库存。
▲ 盘点单列表:盘点录入实盘数量,完成时校验账面快照未被改动,再按盈亏生成调整流水
设计要点:
- 账面快照:盘点明细记录盘点时的账面数量,完成时校验"账面没被别人改过"。
- 盈亏自动算:实盘与账面的差即盈亏,生成对应的库存调整与流水。
- 无盈亏不写:实盘等于账面时不更新库存、不生成流水,避免无效记录。
四、后端核心实现
4.1 单据完成才写库存:以入库单为例
入库单完成时分两步:先用 updateByIdAndStatus 把状态从"草稿"乐观更新为"完成"(防并发重复完成),再调用库存服务写入库存。入库在库存变更模型里用正数数量:
@Override
@Transactional(rollbackFor = Exception.class)
public void completeReceiptOrder(Long id) {
// 1.1 校验存在且为草稿;1.2 校验明细存在
WmsReceiptOrderDO order = validateReceiptOrderPrepare(id);
List<WmsReceiptOrderDetailDO> details = receiptOrderDetailService.validateReceiptOrderDetailListExists(id);
// 2. 乐观更新状态:草稿 → 完成(影响 0 行说明已被并发完成)
if (receiptOrderMapper.updateByIdAndStatus(id, WmsOrderStatusEnum.PREPARE.getStatus(),
new WmsReceiptOrderDO().setStatus(WmsOrderStatusEnum.FINISHED.getStatus())) == 0) {
throw exception(RECEIPT_ORDER_STATUS_NOT_PREPARE);
}
// 3. 写入库存(正数 = 入库)
createInventory(order, details);
}
private void createInventory(WmsReceiptOrderDO order, List<WmsReceiptOrderDetailDO> details) {
List<WmsInventoryChangeReqDTO.Item> items = convertList(details,
d -> BeanUtils.toBean(d, WmsInventoryChangeReqDTO.Item.class));
inventoryService.changeInventory(new WmsInventoryChangeReqDTO()
.setOrderId(order.getId()).setOrderNo(order.getNo())
.setOrderType(WmsOrderTypeEnum.RECEIPT.getType()).setItems(items));
}
出库单、移库单完全同构,只是数量正负不同(出库传负数、移库一出一进)。
4.2 库存变更的并发核心:悲观锁批量锁定 + 内存计算
这是 WMS 最关键的技术点。 与 ERP 的"条件 UPDATE"不同,WMS 走悲观锁路线——先把本次涉及的所有库存行用 SELECT ... FOR UPDATE 批量锁住,再在内存里逐条计算、校验充足性,全部通过后批量更新。一次加锁、一致更新,避免多单并发交错扣减:
private Map<Item, Tuple> changeInventoryList(List<WmsInventoryChangeReqDTO.Item> items) {
// 1.1 创建或定位本次涉及的库存行
List<WmsInventoryDO> inventories = getOrCreateInventoryList(items);
// 1.2 悲观锁:按 ID 批量 SELECT ... FOR UPDATE,锁住这些库存行
inventories = inventoryMapper.selectListByIdsForUpdate(convertSet(inventories, WmsInventoryDO::getId));
// 2.1 在内存里逐条计算变更后数量,并校验库存充足
Map<Item, Tuple> resultMap = new IdentityHashMap<>(items.size());
for (Item item : items) {
WmsInventoryDO inventory = findInventory(inventories, item);
BigDecimal before = inventory.getQuantity();
BigDecimal after = before.add(item.getQuantity()); // 出库时 quantity 为负
if (after.compareTo(BigDecimal.ZERO) < 0) {
throw buildInventoryQuantityNotEnoughException(item, before); // 库存不足
}
inventory.setQuantity(after);
resultMap.put(item, new Tuple(before, after));
}
// 2.2 全部校验通过后,批量更新库存数量(已加锁,安全)
inventoryMapper.updateBatch(convertList(inventories, inv ->
new WmsInventoryDO().setId(inv.getId()).setQuantity(inv.getQuantity())));
return resultMap;
}
为什么用悲观锁而不是条件 UPDATE?因为一次出库往往涉及多个 SKU,且移库还要同时改两个仓库行。把相关库存行一次性锁住、在内存里整体计算校验,能保证"要么整单成功、要么整单失败"的一致性,比逐行条件更新更适合多明细场景。
4.3 库存行缺失:创建 + 唯一索引冲突回查
库存行可能还不存在(某 SKU 第一次入某仓)。系统先批量查已有行,对缺失的执行创建;高并发下两个线程可能同时创建同一行,靠数据库唯一索引拦截,捕获 DuplicateKeyException 后回查已建行:
private List<WmsInventoryDO> createMissingInventoryList(List<WmsInventoryDO> missing) {
List<WmsInventoryDO> created = new ArrayList<>(missing.size());
for (WmsInventoryDO m : missing) {
WmsInventoryDO inventory = new WmsInventoryDO()
.setSkuId(m.getSkuId()).setWarehouseId(m.getWarehouseId()).setQuantity(BigDecimal.ZERO);
try {
inventoryMapper.insert(inventory);
} catch (DuplicateKeyException ex) {
// 并发下别人已创建:按唯一键回查已有库存行
inventory = inventoryMapper.selectBySkuIdAndWarehouseId(m.getSkuId(), m.getWarehouseId());
if (inventory == null) {
throw ex;
}
}
created.add(inventory);
}
return created;
}
4.4 盘点防覆盖:账面快照校验
盘点最怕"边盘边变"——录入实盘时别人又出入了库。系统在盘点完成时用 SELECT ... FOR UPDATE 锁住库存行,并校验"当前账面数量是否仍等于盘点时记录的快照",不一致就报错,避免把别人的变动冲掉:
private WmsInventoryDO getOrCreateCheckInventory(WmsInventoryCheckReqDTO.Item item) {
if (item.getInventoryId() == null) {
return createCheckInventory(item);
}
// 锁定库存行
WmsInventoryDO inventory = inventoryMapper.selectByIdForUpdate(item.getInventoryId());
// 校验:库存行还在、SKU/仓库一致、且账面数量未被改动(与盘点快照一致)
if (inventory == null || !isSameInventory(inventory, item)
|| inventory.getQuantity().compareTo(item.getQuantity()) != 0) {
throw exception(CHECK_ORDER_INVENTORY_CHANGED); // 盘点期间库存已变化
}
return inventory;
}
盘点只在"实盘 ≠ 账面"时才更新库存并写流水(盈亏调整),相等则跳过——避免无效记录。
4.5 库存流水:每次变动都留痕
无论入库、出库、移库还是盘点,库存变更后都生成 wms_inventory_history 流水,记录变更前后数量、单价、关联单据:
private WmsInventoryHistoryDO buildInventoryHistory(WmsInventoryChangeReqDTO reqDTO, Item item, Tuple result) {
return new WmsInventoryHistoryDO()
.setWarehouseId(item.getWarehouseId()).setSkuId(item.getSkuId())
.setQuantity(item.getQuantity()) // 本次变动量(正入负出)
.setBeforeQuantity(result.get(0)).setAfterQuantity(result.get(1)) // 变更前/后
.setPrice(item.getPrice()).setTotalPrice(item.getTotalPrice())
.setOrderId(reqDTO.getOrderId()).setOrderNo(reqDTO.getOrderNo())
.setOrderType(reqDTO.getOrderType()); // 关联单据类型
}
五、RuoYi Office 的创新设计
5.1 商品-SKU 两层主数据,库存精细到规格
WMS 把"商品"和"SKU"分成两层:商品是聚合,SKU 才是带条码、尺寸、重量、成本价的最小库存单元。库存维度是"SKU × 仓库",因此能精细管理多规格、多批次商品——这是 WMS 区别于粗粒度库存系统的根本。
5.2 单据生命周期清晰,"完成"才写库存
四类单据都走"草稿→完成→作废"。草稿态随便改、随便删,"完成"动作才把库存写进去。这种延迟生效的设计让作业有缓冲、可纠错,也让库存变更点高度集中、易于审计。
5.3 悲观锁批量锁定,多明细整单一致
库存变更先 SELECT ... FOR UPDATE 批量锁住相关库存行,再在内存整体计算校验、批量更新。对"一单多 SKU、移库改两仓"的复杂场景,能保证整单原子一致,从根上防并发超卖。
5.4 库存行补建的并发安全
某 SKU 第一次入某仓时库存行还不存在,系统按需创建;并发创建冲突时靠唯一索引拦截 + 回查,保证"库存行不会重复、也不会丢",这是高并发补行的经典处理。
5.5 盘点账面快照校验,杜绝边盘边变
盘点完成时锁定库存行并校验"账面是否仍等于盘点时的快照",不一致直接报错。这避免了"盘点录入慢、期间别人出入库,结果实盘一提交把别人的变动冲掉"的数据事故。
六、数据结构
6.1 表结构:wms_inventory(库存余额,真相源)
| 字段 | 类型 | 说明 |
|---|---|---|
id |
bigint | 主键 |
sku_id |
bigint | 商品 SKU 编号 |
warehouse_id |
bigint | 仓库编号 |
quantity |
decimal | 当前库存数量 |
6.2 表结构:wms_inventory_history(库存流水)
| 字段 | 类型 | 说明 |
|---|---|---|
id |
bigint | 主键 |
sku_id / warehouse_id |
bigint | SKU / 仓库 |
quantity |
decimal | 本次变动量(正入负出) |
before_quantity / after_quantity |
decimal | 变更前 / 变更后数量 |
price / total_price |
decimal | 单价 / 金额 |
order_id / order_no / order_type |
bigint/varchar/int | 关联单据 ID / 单号 / 类型 |
6.3 表结构:wms_item_sku(商品 SKU,节选)
| 字段 | 类型 | 说明 |
|---|---|---|
id / item_id |
bigint | 主键 / 所属商品 |
name / code / bar_code |
varchar | 规格名 / 规格编号 / 条码 |
length / width / height |
decimal | 长 / 宽 / 高(cm) |
gross_weight / net_weight |
decimal | 毛重 / 净重(kg) |
cost_price / selling_price |
decimal | 成本价 / 销售价 |
6.4 表结构:wms_receipt_order(入库单,节选)
| 字段 | 类型 | 说明 |
|---|---|---|
id / no |
bigint/varchar | 主键 / 入库单号 |
type / status |
int | 入库类型 / 状态(草稿/完成/作废) |
warehouse_id / merchant_id |
bigint | 仓库 / 往来单位 |
total_quantity / total_price |
decimal | 总数量 / 总金额 |
6.5 设计要点
- 库存维度唯一:
wms_inventory的(sku_id, warehouse_id)建唯一索引,是补行冲突回查与悲观锁的基础。 - 多租户:所有表基于 Yudao 多租户体系隔离数据。
- 金额数量
BigDecimal:库存数量、单价、金额统一精度。 - 单据状态用字典:入库类型、单据状态用字典维护,前端渲染彩色标签。
七、技术亮点总结
| 设计要点 | 实现方式 | 价值 |
|---|---|---|
| SKU 级库存 | (sku_id, warehouse_id) 维度 |
精细管多规格多仓 |
| 完成才写库存 | complete 动作触发 changeInventory |
草稿可纠错、变更点集中 |
| 状态乐观校验 | updateByIdAndStatus(草稿→完成) |
防并发重复完成 |
| 悲观锁变更 | SELECT ... FOR UPDATE 批量锁定 |
多明细整单一致防超卖 |
| 内存批量计算 | 锁后内存校验充足再批量更新 | 一次锁、一致更新 |
| 库存行补建 | 创建 + 唯一索引冲突回查 | 高并发补行安全 |
| 盘点快照校验 | 账面未变才允许调整 | 杜绝边盘边变覆盖 |
| 库存流水留痕 | wms_inventory_history before/after |
每次变动可追溯 |
| 移库总量守恒 | 一出一进同事务 | 仓间转移不丢量 |
八、快速体验
在线演示:http://ruoyioffice.com/web/(账号 admin / 密码 admin123)
操作路径:WMS 仓储 → 主数据(仓库 / 商品 / SKU)→ 入库单 / 出库单 / 移库单 / 盘点单 → 库存查询 / 库存流水
推荐体验流程:
- 建主数据:维护仓库、商品及其 SKU(规格、条码、成本价)。
- 入库:新建入库单,录 SKU 和数量,先存草稿再点"完成",回到库存查询观察数量增加。
- 出库:新建出库单完成,观察库存减少;对库存不足的 SKU 出大额,观察"库存不足"拦截。
- 移库:新建移库单从 A 仓移到 B 仓,观察两仓库存一减一增、总量不变。
- 盘点:新建盘点单录入实盘数量并完成,观察盈亏调整与流水;尝试模拟"盘点期间库存变化",观察快照校验拦截。
- 查流水:进入库存流水,逐笔核对每次变动的方向、前后数量与关联单据。
源码仓库:
| 平台 | 地址 |
|---|---|
| GitHub | https://github.com/yuqing2026/ruoyi-office |
| GitCode | https://gitcode.com/zhouzhongyan/ruoyi-office |
| Gitee | https://gitee.com/yqzy1688/ruoyi-office |
结语
仓储管理的本质,是在更细的颗粒度(SKU×仓库)和更猛的并发下,把库存管得既准又稳。RuoYi Office 的答案是:用"商品-SKU"两层主数据精细到规格,用四类单据 + "完成才写库存"的生命周期让作业可纠错,用悲观锁批量锁定 + 内存整体计算保证多明细整单一致,用盘点快照校验杜绝边盘边变,用库存流水把每一次变动留痕。
值得一提的是,RuoYi Office 里 WMS 与 ERP 进销存采用了两种不同的库存并发策略——ERP 用"条件 UPDATE(乐观)“,WMS 用"SELECT FOR UPDATE(悲观)”。这两种方案的取舍,我们在本周的《库存扣减的并发难题》里做了系统对比。
如果你正在设计 WMS 或库存系统,欢迎参考源码实现,也欢迎在评论区聊聊:你们仓库的盘点,是怎么防止"边盘边变"对不上账的?
常见问题(FAQ)
RuoYi Office 的 WMS 是开源免费的吗?
是。仓储管理模块(yudao-module-wms)基于 RuoYi-Vue-Pro / Yudao 架构,后端 Spring Boot 3.5 + 前端 Vue3,开源可商用、无 license 限制,本地约 10 分钟即可启动体验。
WMS 和进销存的库存有什么区别?
进销存(ERP)库存维度是"产品 × 仓库",偏经营视角;WMS 库存维度是"SKU × 仓库",精细到规格/批次,偏仓储作业视角,并发更严苛。RuoYi Office 同时提供这两个模块,可按需选用。
WMS 怎么防止并发出库超卖?
WMS 用悲观锁:库存变更时先 SELECT ... FOR UPDATE 批量锁住相关库存行,再在内存里整体计算、校验充足、批量更新,保证多明细整单一致,从根上防超卖。详见本周文章《库存扣减的并发难题》。
单据建好就会扣库存吗?
不会。四类单据都走"草稿→完成→作废",草稿态可反复编辑、删除,只有点"完成"才真正写库存,作废只能在草稿态进行。库存变更点高度集中,便于审计。
盘点时别人改了库存会不会冲突?
会被拦截。盘点完成时锁定库存行并校验"账面是否仍等于盘点时的快照",不一致直接报"盘点期间库存已变化",避免把别人的变动覆盖掉。
💡 想要体验 RuoYi Office 的强大功能?
🌐 在线演示:http://ruoyioffice.com/web/(账号 admin / admin123)
📦 源码仓库:GitHub | GitCode | Gitee
💬 技术咨询:添加微信 17156169080,备注「RuoYi Office」
⭐ 如果觉得不错,请给个 Star 支持一下!
更多推荐



所有评论(0)