SpringBoot+Vue3 仓储管理系统(WMS)设计:商品·SKU·出入库·移库·盘点全流程拆解

🌐 演示地址http://ruoyioffice.com | 📦 源码1·GitHubruoyi-office | 📦 源码2·GitCoderuoyi-office | 📦 源码3·Giteeruoyi-office | 💬 微信:17156169080(备注「RuoYi Office」)

进销存里的库存只回答"还剩多少",而仓储管理系统(WMS,Warehouse Management System)要回答更细的问题:哪个 SKU 在哪个仓库、出入库怎么走单、跨仓移库怎么记、盘点对不上账怎么调。WMS 是物流仓储的执行层,并发尤其严苛——多个出库单可能同时扣同一个 SKU 的库存。RuoYi Office 的仓储模块(yudao-module-wms)以"商品-SKU"主数据 + "SKU × 仓库"库存余额为核心,用入库单、出库单、移库单、盘点单四类单据驱动库存,单据走草稿→完成→作废,完成时才真正写库存;库存变更用悲观锁 SELECT ... FOR UPDATE 批量锁定后在内存计算校验,从根上杜绝并发超卖,每一次变动都落 wms_inventory_history 流水可追溯。
wms-warehouse-management-architecture.png

▲ 全景图:商品-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 × 仓库"展示当前数量,是仓库的实时全局视图。
wms-inventory-list.png
▲ 库存列表:每行是一个"SKU × 仓库"的当前余额,数量由四类单据完成时变更,可下钻到库存流水查看每一次出入

设计要点

  • SKU 级精度:同一商品不同规格分别记库存,库存维度是 SKU 而非商品。
  • 多仓并行:同一 SKU 在不同仓库各有一行库存,支持跨仓查询与移库。
  • 只读不可手改:库存数量不能直接编辑,只能通过四类单据变更。

3.2 入库单列表

入库单是"货进仓"的单据,草稿态可编辑,完成时给对应仓库加库存。
wms-receipt-order-list.png

▲ 入库单列表:状态分草稿/完成/作废,只有点"完成"才真正写库存并落入库流水;草稿态可改可删

设计要点

  • 完成才生效:草稿态不动库存,"完成"动作才把明细数量加进库存。
  • 金额自动汇总:总数量、总金额由明细行自动汇总(明细可直接填行金额或按单价×数量算)。
  • 类型可配:入库类型(采购入库/退货入库/其它入库)用字典配置。

3.3 盘点单列表

盘点单用于"账实对账",录入实盘数量后按盈亏调平账面库存。
wms-check-order-list.png
▲ 盘点单列表:盘点录入实盘数量,完成时校验账面快照未被改动,再按盈亏生成调整流水

设计要点

  • 账面快照:盘点明细记录盘点时的账面数量,完成时校验"账面没被别人改过"。
  • 盈亏自动算:实盘与账面的差即盈亏,生成对应的库存调整与流水。
  • 无盈亏不写:实盘等于账面时不更新库存、不生成流水,避免无效记录。

四、后端核心实现

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)→ 入库单 / 出库单 / 移库单 / 盘点单 → 库存查询 / 库存流水

推荐体验流程

  1. 建主数据:维护仓库、商品及其 SKU(规格、条码、成本价)。
  2. 入库:新建入库单,录 SKU 和数量,先存草稿再点"完成",回到库存查询观察数量增加。
  3. 出库:新建出库单完成,观察库存减少;对库存不足的 SKU 出大额,观察"库存不足"拦截。
  4. 移库:新建移库单从 A 仓移到 B 仓,观察两仓库存一减一增、总量不变。
  5. 盘点:新建盘点单录入实盘数量并完成,观察盈亏调整与流水;尝试模拟"盘点期间库存变化",观察快照校验拦截。
  6. 查流水:进入库存流水,逐笔核对每次变动的方向、前后数量与关联单据。

源码仓库

平台 地址
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 支持一下!


Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐