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)publicvoidcompleteReceiptOrder(Longid){// 1.1 校验存在且为草稿;1.2 校验明细存在WmsReceiptOrderDOorder=validateReceiptOrderPrepare(id);List<WmsReceiptOrderDetailDO>details=receiptOrderDetailService.validateReceiptOrderDetailListExists(id);// 2. 乐观更新状态:草稿 → 完成(影响 0 行说明已被并发完成)if(receiptOrderMapper.updateByIdAndStatus(id,WmsOrderStatusEnum.PREPARE.getStatus(),newWmsReceiptOrderDO().setStatus(WmsOrderStatusEnum.FINISHED.getStatus()))==0){throwexception(RECEIPT_ORDER_STATUS_NOT_PREPARE);}// 3. 写入库存(正数 = 入库)createInventory(order,details);}privatevoidcreateInventory(WmsReceiptOrderDOorder,List<WmsReceiptOrderDetailDO>details){List<WmsInventoryChangeReqDTO.Item>items=convertList(details,d->BeanUtils.toBean(d,WmsInventoryChangeReqDTO.Item.class));inventoryService.changeInventory(newWmsInventoryChangeReqDTO().setOrderId(order.getId()).setOrderNo(order.getNo()).setOrderType(WmsOrderTypeEnum.RECEIPT.getType()).setItems(items));}出库单、移库单完全同构,只是数量正负不同(出库传负数、移库一出一进)。
4.2 库存变更的并发核心:悲观锁批量锁定 + 内存计算
这是 WMS 最关键的技术点。与 ERP 的"条件 UPDATE"不同,WMS 走悲观锁路线——先把本次涉及的所有库存行用SELECT ... FOR UPDATE批量锁住,再在内存里逐条计算、校验充足性,全部通过后批量更新。一次加锁、一致更新,避免多单并发交错扣减:
privateMap<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=newIdentityHashMap<>(items.size());for(Itemitem:items){WmsInventoryDOinventory=findInventory(inventories,item);BigDecimalbefore=inventory.getQuantity();BigDecimalafter=before.add(item.getQuantity());// 出库时 quantity 为负if(after.compareTo(BigDecimal.ZERO)<0){throwbuildInventoryQuantityNotEnoughException(item,before);// 库存不足}inventory.setQuantity(after);resultMap.put(item,newTuple(before,after));}// 2.2 全部校验通过后,批量更新库存数量(已加锁,安全)inventoryMapper.updateBatch(convertList(inventories,inv->newWmsInventoryDO().setId(inv.getId()).setQuantity(inv.getQuantity())));returnresultMap;}为什么用悲观锁而不是条件 UPDATE?因为一次出库往往涉及多个 SKU,且移库还要同时改两个仓库行。把相关库存行一次性锁住、在内存里整体计算校验,能保证"要么整单成功、要么整单失败"的一致性,比逐行条件更新更适合多明细场景。
4.3 库存行缺失:创建 + 唯一索引冲突回查
库存行可能还不存在(某 SKU 第一次入某仓)。系统先批量查已有行,对缺失的执行创建;高并发下两个线程可能同时创建同一行,靠数据库唯一索引拦截,捕获DuplicateKeyException后回查已建行:
privateList<WmsInventoryDO>createMissingInventoryList(List<WmsInventoryDO>missing){List<WmsInventoryDO>created=newArrayList<>(missing.size());for(WmsInventoryDOm:missing){WmsInventoryDOinventory=newWmsInventoryDO().setSkuId(m.getSkuId()).setWarehouseId(m.getWarehouseId()).setQuantity(BigDecimal.ZERO);try{inventoryMapper.insert(inventory);}catch(DuplicateKeyExceptionex){// 并发下别人已创建:按唯一键回查已有库存行inventory=inventoryMapper.selectBySkuIdAndWarehouseId(m.getSkuId(),m.getWarehouseId());if(inventory==null){throwex;}}created.add(inventory);}returncreated;}4.4 盘点防覆盖:账面快照校验
盘点最怕"边盘边变"——录入实盘时别人又出入了库。系统在盘点完成时用SELECT ... FOR UPDATE锁住库存行,并校验"当前账面数量是否仍等于盘点时记录的快照",不一致就报错,避免把别人的变动冲掉:
privateWmsInventoryDOgetOrCreateCheckInventory(WmsInventoryCheckReqDTO.Itemitem){if(item.getInventoryId()==null){returncreateCheckInventory(item);}// 锁定库存行WmsInventoryDOinventory=inventoryMapper.selectByIdForUpdate(item.getInventoryId());// 校验:库存行还在、SKU/仓库一致、且账面数量未被改动(与盘点快照一致)if(inventory==null||!isSameInventory(inventory,item)||inventory.getQuantity().compareTo(item.getQuantity())!=0){throwexception(CHECK_ORDER_INVENTORY_CHANGED);// 盘点期间库存已变化}returninventory;}盘点只在"实盘 ≠ 账面"时才更新库存并写流水(盈亏调整),相等则跳过——避免无效记录。
4.5 库存流水:每次变动都留痕
无论入库、出库、移库还是盘点,库存变更后都生成wms_inventory_history流水,记录变更前后数量、单价、关联单据:
privateWmsInventoryHistoryDObuildInventoryHistory(WmsInventoryChangeReqDTOreqDTO,Itemitem,Tupleresult){returnnewWmsInventoryHistoryDO().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_historybefore/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 支持一下!