云计算百科
云计算领域专业知识百科平台

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

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

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

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

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

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

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

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

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

设计库存预留之前,要先把 On Hand、Reserved 和 Available 分开。
在这里插入图片描述

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

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

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

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

Available for Sale = On Hand – Active Reserved

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

Available for Sale = On Hand – Active Reserved – Active Hold

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

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

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

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

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

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

举个例子:

仓库现存:100
客户 A 待支付订单预留:30
客户 B 待支付订单预留:20

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

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

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

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

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

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

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

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

所以更合理的链路是:

订单创建完成
-> 校验可售库存
-> 创建库存预留
-> 可售库存下降

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

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

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

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

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

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

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

  • 订单待支付不代表一定预留成功。
  • 订单已支付不代表库存已经真实出库。
  • 订单编辑后,旧明细对应的预留需要释放,新明细需要重新预留。
  • 订单取消时,业务状态变了,但库存承诺也必须被释放。
  • 后续如果支持部分发货、部分取消、分仓履约,订单状态和预留状态会天然一对多。
  • 更稳的做法是给库存预留建立独立生命周期。
    在这里插入图片描述

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

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

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

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

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

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

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

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

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

    6. 推荐的领域结构

    我更倾向于把这件事拆成四层。
    在这里插入图片描述
    第一层是库存事实层。

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

    第二层是库存预留层。

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

    第三层是可售库存服务。

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

    query On Hand
    sum Active Reserved
    calculate Available for Sale
    check whether the requested quantity can be promised

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

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

    用一句话概括:

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

    7. 并发下不能先查后插

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

    先查 available = 100
    判断 100 >= 80
    插入一条预留 80

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

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

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

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

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

    伪 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. 编辑订单比创建订单更容易出错

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

    比如原订单是:

    商品 A:10 件
    商品 B:5 件

    用户编辑成:

    商品 A:3 件
    商品 C:8 件

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

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

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

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

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

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

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

    假设仓库现存 100 件。

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

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

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

    本单可用 = On Hand – 其他订单 Active Reserved

    也可以理解成:

    本单可用 = 全局 Available + 本单 Active Reserved

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

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

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

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

    理想流程是:

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

    这里有两个关键点。

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

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

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

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

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

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

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

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

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

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

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

    这会造成口径断层。

    更稳的 cutover 流程是:

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

    开启开关时最好做门禁:

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

    关闭开关时也要做门禁:

    如果仍存在有效预留,不允许关闭

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

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

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

    仓库明明还有 100 件,为什么只能卖 50 件?

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

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

    现存库存:100
    已承诺库存:50
    可售库存:50

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

    13. 常见反模式

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

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

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

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

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

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

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

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

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

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

    14. 我会如何设计第一版

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

  • 建库存预留主表和明细表,保留业务单据维度、订单行维度、仓库、商品、批次、单位、数量、状态和幂等键。
  • 建可售库存服务,禁止订单链路继续直接把现存库存当可售库存。
  • 订单创建成功后,在同一事务内创建预留;预留失败则订单创建失败。
  • 待支付订单编辑时,全量释放旧预留并重建新预留,失败则编辑回滚。
  • 取消、超时、删除待支付订单时释放预留。
  • 支付、发货或出库时,将预留转换为真实库存消耗。
  • 支付前校验使用“全局可售 + 本单预留”的口径。
  • 上线时开关默认关闭,先做历史待支付单回填,再灰度开启。
  • 库存列表逐步补充现存、已承诺、可售三个展示字段。
  • 这套设计没有试图一次做完完整 ERP 库存体系,但它把最重要的边界立住了。

    15. 结语

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

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

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

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

    本文为作者原创,首发于掘金,CSDN 为同步发布版本。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 库存不是一个数字-从-On-Hand-到-Available-Reserved-的销售订单库存预留设计
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!