库存不是一个数字:从 On Hand 到 Available、Reserved 的销售订单库存预留设计

库存不是一个数字:从 On Hand 到 Available、Reserved 的销售订单库存预留设计

很多业务系统一开始都会把库存理解成一个字段。

商品有库存,库存够就能卖,库存不够就拦住。这个模型在零售 POS 的瞬时交易里通常能跑起来:顾客下单、收款、出货几乎发生在同一条链路上,系统在支付成功时扣库存,问题不大。

但一旦销售订单进入批发、赊账、订货、长周期履约场景,问题就出来了。

客户今天创建了一张待支付销售单,约定了 100 件货。库存表里还有 100 件。因为还没付款,系统没有扣库存。第二个业务员看到库存仍然是 100 件,又卖了一张 100 件的订单。等第一张订单支付、审核或发货时,系统才发现库存不够。

这不是支付校验写得不够严,也不是把 SQL 再加一层锁就能解决的问题。根因是系统缺少 ERP 库存模型里非常关键的一层:订单承诺库存

换句话说,库存不是一个数字。

1. 先把三个库存口径分清楚

设计库存预留之前,要先把 On HandReservedAvailable 分开。

On Hand 是现存库存,也就是仓库当前真实持有的实物数量。它回答的是:仓库里现在有多少货。

Reserved 是已承诺库存,也可以叫已预留库存、已占用库存。它回答的是:这些货虽然还在仓库里,但已经承诺给某些业务单据,不能再随便卖给别人。

Available 是可售库存。它回答的是:现在还可以继续承诺给新订单的数量。

一期最核心的公式很简单:

text 复制代码
Available for Sale = On Hand - Active Reserved

未来如果系统继续扩展损坏冻结、质检冻结、安全库存、草稿单保留等能力,公式会变成:

text 复制代码
Available for Sale = On Hand - Active Reserved - Active Hold

在途采购、调拨在途、预计生产入库属于供给侧信息。它可以参与补货计划和预计可承诺日期,但默认不应该直接变成当前可售库存。货没入库,就不能当成仓库现有货来卖。

2. 库存预留不等于库存扣减

这是整套设计里最容易被混淆的地方。

库存扣减表达的是实物库存已经发生变化。比如支付后立即出货、仓库确认发货、生产领料出库,这些动作会减少 On Hand,会写库存流水,后续还可能影响成本和财务口径。

库存预留表达的是业务承诺已经发生,但实物还没离开仓库。它不应该减少 On Hand,也不应该写真实库存流水,更不应该提前触发财务成本。

它只影响一件事:可售库存

举个例子:

text 复制代码
仓库现存:100
客户 A 待支付订单预留:30
客户 B 待支付订单预留:20

现存库存 On Hand = 100
已承诺库存 Reserved = 50
可售库存 Available = 50

此时仓库里仍然有 100 件货。盘点时也应该看到 100 件。但销售端不能再认为还有 100 件可卖,因为其中 50 件已经被订单承诺占用了。

这就是"预留"和"扣减"的边界。

3. 为什么不能只在支付时校验库存

很多系统早期会采用支付成功时再校验和扣库存的方案。这个方案适合一个前提:订单创建到支付之间的时间窗口很短。

零售 POS 一般符合这个前提。顾客就在收银台前,订单从创建到支付只隔几秒钟。即使并发冲突,用户通常也能接受"支付时库存不足"的提示。

但批发、订货、赊账和长周期销售订单不一样。

待支付订单可能存在几个小时、几天甚至更久。业务员已经对客户做了承诺,客户可能已经安排了后续计划,仓库也可能开始备货。如果系统仍然等到支付或出库时才校验库存,那么库存不足就会从一个技术问题变成履约、客服和经营风险。

所以更合理的链路是:

text 复制代码
订单创建完成
  -> 校验可售库存
  -> 创建库存预留
  -> 可售库存下降

订单取消、超时、作废
  -> 释放库存预留
  -> 可售库存恢复

支付、发货或出库
  -> 将预留转为真实库存消耗
  -> On Hand 下降,Reserved 下降

注意这里有一个关键变化:创建订单时不是扣库存,而是先占用可售库存。

4. 订单状态和预留状态要解耦

销售订单有自己的状态,比如待支付、已支付、已取消、已完成。

库存预留也应该有自己的状态,比如有效、已释放、已转实扣、部分转实扣。

不要把库存预留直接塞进订单状态里。否则后续会遇到几个问题:

  1. 订单待支付不代表一定预留成功。
  2. 订单已支付不代表库存已经真实出库。
  3. 订单编辑后,旧明细对应的预留需要释放,新明细需要重新预留。
  4. 订单取消时,业务状态变了,但库存承诺也必须被释放。
  5. 后续如果支持部分发货、部分取消、分仓履约,订单状态和预留状态会天然一对多。

更稳的做法是给库存预留建立独立生命周期。

订单只是触发方,库存预留层负责表达"这张订单当前占用了多少可售库存"。

5. 一期可以只做销售订单预留,但边界要留对

完整 ERP 库存体系很大,不能一次性塞进一个版本。

一期真正要解决的问题是:待支付或长周期销售订单重复销售同一批库存。

因此可以先只做这条主链路:

text 复制代码
待支付普通销售单创建:创建预留
待支付普通销售单编辑:释放旧预留,重建新预留
订单取消或超时:释放预留
支付或出库:预留转库存消耗

暂时不做的能力要明确写在边界里:

text 复制代码
不做完整 ATP 预计可承诺日期
不做订单优先级重分配
不做手动抢占其他订单库存
不做 Backorder 缺货接单
不做完整 stock_hold 冻结库存体系
不做采购在途直接转可售
不做客户寄售、代管库存所有权体系

这些不是不重要,而是它们不应该和第一版销售订单预留混在一起。第一版只要把可售库存口径、订单预留账、真实库存消耗边界打稳,就已经能解决大量业务风险。

6. 推荐的领域结构

我更倾向于把这件事拆成四层。

第一层是库存事实层。

它只表达真实库存余额和真实库存流水。比如商品在某仓库、某批次、某仓位当前有多少库存,发生了哪些入库、出库、反冲、调整。

第二层是库存预留层。

它表达销售订单造成的需求侧承诺。可以有一张预留主表和一张预留明细表:主表记录业务单据、状态、版本、来源;明细记录商品、仓库、批次、单位和预留数量。

第三层是可售库存服务。

订单链路不应该再直接把库存余额当成可售库存,而应该统一走可售库存服务:

text 复制代码
query On Hand
sum Active Reserved
calculate Available for Sale
check whether the requested quantity can be promised

第四层是真实库存消耗编排。

支付、发货、出库时,它负责减少库存余额、写库存流水、处理幂等、异常和反冲。库存预留只是在这个阶段被转换,不替代库存消耗。

用一句话概括:

text 复制代码
库存事实层回答"仓库里有什么"。
预留层回答"哪些库存已经承诺出去了"。
可售服务回答"还能不能继续卖"。
消耗编排回答"库存为什么真的变少了"。

7. 并发下不能先查后插

库存预留最危险的实现方式是:

text 复制代码
先查 available = 100
判断 100 >= 80
插入一条预留 80

如果两个订单并发执行,它们可能同时读到 available = 100,然后都成功插入预留,最终预留 160,直接超占。

所以预留创建必须满足几个原则。

第一,预留校验和写入要在同一事务里完成。

第二,同一库存事实粒度要串行化或原子更新。粒度至少要包括租户、仓库、商品,启用批次、仓位后还要继续细化。

第三,不能只靠业务层先查再判断,要有数据库层面的并发保护。常见方式是锁住库存事实行后再汇总有效预留,或者维护可售/已预留汇总字段并通过条件更新保证不超占。

伪 SQL 可以这样理解:

sql 复制代码
UPDATE inventory_balance
SET reserved_qty = reserved_qty + :reserveQty,
    version = version + 1
WHERE tenant_id = :tenantId
  AND warehouse_id = :warehouseId
  AND sku_id = :skuId
  AND on_hand_qty - reserved_qty >= :reserveQty;

如果影响行数为 0,就说明并发下可售库存已经不足,不能继续创建预留。

如果系统不维护 reserved_qty 汇总字段,也可以在事务内锁库存事实行,然后二次汇总有效预留再写预留明细。关键不是选择哪一种表结构,而是不能让两个事务都基于同一个旧快照做承诺。

8. 编辑订单比创建订单更容易出错

销售订单创建时预留一次还比较直观。真正容易出问题的是待支付订单编辑。

比如原订单是:

text 复制代码
商品 A:10 件
商品 B:5 件

用户编辑成:

text 复制代码
商品 A:3 件
商品 C:8 件

这时不能只在新明细上补差,也不能只更新订单明细。稳妥的第一版做法是:

text 复制代码
查询旧订单和旧预留
释放旧预留
替换订单明细
基于新明细重新创建预留

这几步必须在同一个事务内完成。

如果新明细预留失败,订单编辑也应该失败,旧订单和旧预留都不能被破坏。否则就会出现"订单明细已经变了,但库存预留还是旧的"这种脏状态。

如果后续系统要支持部分预留、分仓分批履约,可以再做差量算法。但第一版没有必要一上来就追求复杂差量更新。对销售订单这种明细规模通常有限的场景,全量释放 + 全量重建更容易保证正确性。

9. 支付时要把"本单预留"加回来

启用库存预留后,支付前校验的口径也要变化。

假设仓库现存 100 件。

订单 A 创建时预留了 80 件。全局可售库存变成 20 件。

当订单 A 自己发起支付时,如果系统直接用全局可售 20 去判断,就会错误地认为订单 A 的 80 件库存不足。

所以支付当前订单时,应该使用:

text 复制代码
本单可用 = On Hand - 其他订单 Active Reserved

也可以理解成:

text 复制代码
本单可用 = 全局 Available + 本单 Active Reserved

他人的预留不能算给本单,但本单已经占住的预留要算回来。

这也是为什么库存预留需要能按业务单据维度查询,而不能只维护一个粗粒度的 reserved_qty 汇总数字。汇总字段可以提升性能,但明细账仍然要存在,否则支付、释放、编辑和对账都会失去依据。

10. 预留转实扣要守住事务边界

支付、发货或出库时,系统要把预留转为真实库存消耗。

理想流程是:

text 复制代码
校验订单状态
校验有效预留与订单明细一致
提交库存消耗
减少 On Hand
减少 Reserved
写库存流水
更新订单支付/出库状态

这里有两个关键点。

第一,预留和订单明细要能对得上。

如果订单明细已经被异常链路修改,而预留没有重建,支付时不能继续扣库存。更合适的处理是返回"预留与订单明细不一致",要求用户刷新订单或重新保存,而不是把它当成普通库存不足。

普通库存不足表示:库存确实不够。

预留不一致表示:系统内的订单承诺账和订单明细账已经不一致,需要先修账。

第二,转实扣不能拆成多个没有保护的异步动作。

如果先释放预留,再扣库存失败,就会出现库存没有扣,但可售库存已经恢复,其他订单可能继续卖。

如果先扣库存,再标记预留已转换失败,就会出现库存扣了,但预留仍然占着,导致可售库存被重复压低。

第一版最稳的策略是:订单状态、库存余额、库存流水、预留状态在同一个后端事务边界内闭环。等系统有更强的事件账本和补偿能力后,再考虑拆异步。

11. 上线时最容易忽略历史待支付订单

库存预留不是一个普通功能开关。它改变的是可售库存口径。

如果系统已经存在大量待支付订单,上线后直接打开预留开关,会遇到一个问题:历史订单没有预留,但新订单开始按预留口径校验。

这会造成口径断层。

更稳的 cutover 流程是:

text 复制代码
先上线表结构
再上线应用代码,开关默认关闭
选择商户灰度
按订单创建时间回填历史待支付普通订单预留
确认回填结果
再打开库存预留开关

开启开关时最好做门禁:

text 复制代码
如果仍存在待支付普通订单且没有有效预留,不允许开启

关闭开关时也要做门禁:

text 复制代码
如果仍存在有效预留,不允许关闭

否则系统很容易进入半新半旧的库存口径。

12. 报表一定要解释"库存为什么没少但不能卖"

库存预留上线后,用户最常见的疑问会是:

text 复制代码
仓库明明还有 100 件,为什么只能卖 50 件?

如果库存列表只展示一个"库存数量",用户会认为系统少货或算错。

所以库存查询、库存分布看板、销售订单支付库存不足弹窗,都应该逐步展示三个口径:

text 复制代码
现存库存:100
已承诺库存:50
可售库存:50

对业务用户来说,"已承诺库存"通常比"预留库存"更容易理解。预留是技术动作,承诺是业务事实。

13. 常见反模式

第一种反模式:下单时直接扣库存。

这样虽然能避免超卖,但会污染库存流水。订单取消时还要反冲库存,如果中间涉及财务、成本或批次追溯,后续会越来越难解释。

第二种反模式:只在 Redis 里占库存。

Redis 可以用于秒杀、高并发缓冲或短期锁,但 ERP 里的订单承诺需要可审计、可回放、可对账。最终一定要落到数据库账本里。

第三种反模式:预留没有业务单据维度。

只有 SKU 级汇总,没有订单明细级预留,后续就无法回答"这 50 件到底被哪些订单占用了",也无法准确释放、转换和回填。

第四种反模式:订单编辑只改明细,不重建预留。

这是制造幽灵预留和脏可售库存的高发路径。

第五种反模式:把预留失败混进订单状态。

订单审核、订单支付、库存预留、库存出库是不同事实。它们可以互相约束,但不应该互相替代。

14. 我会如何设计第一版

如果让我给一套 SaaS ERP 的销售订单库存预留做第一版,我会按这个顺序落地:

  1. 建库存预留主表和明细表,保留业务单据维度、订单行维度、仓库、商品、批次、单位、数量、状态和幂等键。
  2. 建可售库存服务,禁止订单链路继续直接把现存库存当可售库存。
  3. 订单创建成功后,在同一事务内创建预留;预留失败则订单创建失败。
  4. 待支付订单编辑时,全量释放旧预留并重建新预留,失败则编辑回滚。
  5. 取消、超时、删除待支付订单时释放预留。
  6. 支付、发货或出库时,将预留转换为真实库存消耗。
  7. 支付前校验使用"全局可售 + 本单预留"的口径。
  8. 上线时开关默认关闭,先做历史待支付单回填,再灰度开启。
  9. 库存列表逐步补充现存、已承诺、可售三个展示字段。

这套设计没有试图一次做完完整 ERP 库存体系,但它把最重要的边界立住了。

15. 结语

库存预留解决的不是"库存字段怎么减"的问题,而是"系统什么时候对客户做出库存承诺"的问题。

只维护一个库存数字时,系统只能回答仓库里还有多少货。引入 Reserved 之后,系统才能回答哪些货已经被订单占住。再通过 Available,系统才知道还可以继续卖多少。

对于 SaaS ERP 来说,这个边界非常重要。

预留不是扣减,承诺不是出库,可售也不是现存。把这三件事拆开,销售订单、库存流水、履约和财务后续才有继续演进的空间。

相关推荐
博、、32 分钟前
本地AI智慧电商平台定制开发:技术架构与实战指南
人工智能·架构
老郑聊AI业财智造35 分钟前
数据不搬家,也能做检索:Milvus的“湖原生”架构革命
人工智能·ai·架构·软件工程·软件构建·milvus
shiyi.十一1 小时前
第8章:计算机网络中的安全 — 知识要点与架构
计算机网络·安全·架构
国科安芯2 小时前
小卫星综合电子系统中RISC-V抗辐射MCU的功能安全与多接口集成架构分析
单片机·嵌入式硬件·安全·fpga开发·架构·risc-v
深念Y3 小时前
微服务抽取路线图:从胖单体到 ARM 集群
前端·arm开发·数据库·后端·微服务·云原生·架构
凤山老林3 小时前
高可靠 API 网关架构:Spring Cloud Gateway 集成 Sentinel 实现智能限流、动态路由与安全管控
安全·spring cloud·架构·sentinel
我滴老baby3 小时前
部署 Portainer CE,把日志、镜像和数据卷搬进网页
数据库·人工智能·架构
^酸酸3 小时前
Prometheus 监控架构部署实战:二进制与 Docker 双方案
docker·架构·prometheus
seacracker3 小时前
从本地磁盘到 NVMe-oF target:PowerFS 存储后端抽象的预留式分层设计
架构·ai存储·统一存储·powerfs