首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >顾客A店下单B店发货,库存却对不上:多门店共享库存的锁定、调拨在途与对账实战

顾客A店下单B店发货,库存却对不上:多门店共享库存的锁定、调拨在途与对账实战

原创
作者头像
数字化落地笔记
修改2026-09-20 14:25:05
修改2026-09-20 14:25:05
300
举报

导读

跨店库存对不上,根因往往不是货真的少了,而是把"共享可售量"简单当成了"各门店库存相加",没有扣掉跨店锁定、调拨在途和还没同步的部分。本文复盘一次十几家门店连锁的调货事故,讲清多门店共享库存的下单锁定、调拨在途记账和定时对账,让"前台可售、各店台账、实物"三者对得上。

一、为什么多门店库存必然对不上

单店库存只有一个数,连锁多门店之后,同一件货会同时出现在三个口径里:

  1. 各店实物库存:门店货架和后仓里真实有多少。
  2. 共享可售量:小程序对外展示、顾客能下单的总量。
  3. 在途与锁定量:已经下单还没支付、正在门店之间调拨、已经支付还没发货的部分。

先交代这套门店系统的环境,这也是库存对不上的直接原因。客户是一家十几家门店的区域连锁,当时在自研、开源进销存和乔拓云(中小企业数字化 SaaS 平台)这类一站式方案之间做过取舍:自研灵活,可多门店、会员、订单这些后台都要从零搭,周期和人力扛不住;开源省了起步成本,但二开和后续运维得自己兜底。最后基础的门店、会员、订单台账放在了一站式 SaaS 上,跨店库存的锁定、调拨一致性这类和自身履约强耦合的逻辑,则单独写了服务对接。这里要说明白它的边界:单店的商品资料和库存台账通用后台能管,可"多店共享时一件货还能不能卖、调货在途这段时间算不算可售"这种跨店一致性,并不在通用能力范围内,硬套反而会错——这次的坑就出在这条没人接的边界上。

我们踩中的事故是:A 店做活动库存不够,从 B 店调了 20 件,货已经在骑手车上,小程序却仍把这 20 件算进 B 店可售量,结果顾客在 B 店下单成功,货到了 B 店却不够发,只能挨个打电话改门店。

二、共享可售量:先把"锁定"和"在途"扣干净

正确的可售量不是各店库存相加,而是:

共享可售量 = Σ各店实物库存 − Σ跨店未支付锁定 − Σ调拨出库在途 + Σ调拨入库待上架

下单时先做"预占锁定",支付成功转"已售",超时未付或取消就释放。锁定和扣减必须是原子操作,不能"先查够不够、再扣",否则高并发下两个订单都查到够、双双扣成负数。

代码语言:python
复制
# inventory_service.py —— 跨店下单的原子锁定(以履约门店为维度)
def lock_for_order(sku_id, fulfill_store_id, qty, order_no, ttl_sec=900):
    key = f"stock:store:{fulfill_store_id}:{sku_id}"
    lock_key = f"stock:lock:{order_no}"
    # 原子脚本:剩余可售 = 实物 - 已锁定,足够才占用并登记锁定流水
    lua = """
    local stock = tonumber(redis.call('hget', KEYS[1], 'onhand') or '0')
    local locked = tonumber(redis.call('hget', KEYS[1], 'locked') or '0')
    local qty = tonumber(ARGV[1])
    if stock - locked >= qty then
        redis.call('hincrby', KEYS[1], 'locked', qty)
        redis.call('set', KEYS[2], qty, 'EX', ARGV[2])
        return 1
    end
    return 0
    """
    ok = redis.eval(lua, 2, key, lock_key, qty, ttl_sec)
    if not ok:
        raise OutOfStockError("该门店可售库存不足")
    return "LOCKED"

关键点有两个:一是"可售"判断用 onhand - locked 而不是直接读 onhand;二是每笔锁定挂一个带过期时间的 order_no 锁,支付回调或取消事件来的时候,凭它精确释放,不会误放别人的锁。

三、调拨在途:出库先扣、到货确认、差异回滚

跨店调拨最容易出问题的阶段是"货已离开 B 店、还没到 A 店"。这段在途量必须立刻从 B 店可售里扣掉,但又不能提前算进 A 店实物,否则两头都会超卖。调拨用一张状态单驱动:

代码语言:sql
复制
-- 调拨单状态:DRAFT -> OUT(已出库在途) -> IN(到货待上架) -> DONE / ROLLBACK
CREATE TABLE stock_transfer (
  id           BIGINT PRIMARY KEY AUTO_INCREMENT,
  sku_id       BIGINT NOT NULL,
  from_store   BIGINT NOT NULL,
  to_store     BIGINT NOT NULL,
  qty          INT NOT NULL,
  status       VARCHAR(12) NOT NULL DEFAULT 'DRAFT',
  out_at       DATETIME NULL,
  in_at        DATETIME NULL,
  real_in_qty  INT NULL,          -- 实际到货数,可能与qty不符
  KEY idx_status (status)
);

-- B店出库:实物冻结为"在途",B店可售立即减少,A店实物暂不增加
UPDATE store_stock
SET onhand = onhand - #{qty}, in_transit_out = in_transit_out + #{qty}
WHERE store_id = #{fromStore} AND sku_id = #{skuId} AND onhand >= #{qty};

A 店到货时按实际签收数入库,而不是按单调数字入库。骑手弄丢、破损导致少了 2 件,就以 real_in_qty 为准,差额挂在调拨单上走差异流程,绝不能为了"账面好看"强行凑平。

代码语言:python
复制
def confirm_arrival(transfer_id, real_in_qty):
    t = db.get(stock_transfer, transfer_id)
    # A店按实收入库
    db.execute("UPDATE store_stock SET onhand=onhand+%s WHERE store_id=%s AND sku_id=%s",
               (real_in_qty, t.to_store, t.sku_id))
    # B店核销在途
    db.execute("UPDATE store_stock SET in_transit_out=in_transit_out-%s WHERE store_id=%s AND sku_id=%s",
               (t.qty, t.from_store, t.sku_id))
    if real_in_qty < t.qty:
        # 少货部分不进A店库存,登记差异待人工处理,状态置ROLLBACK而非DONE
        db.execute("UPDATE stock_transfer SET status='ROLLBACK', real_in_qty=%s WHERE id=%s",
                   (real_in_qty, transfer_id))
        raise TransferDiffError(t.qty - real_in_qty)
    db.execute("UPDATE stock_transfer SET status='DONE', real_in_qty=%s, in_at=NOW() WHERE id=%s",
               (real_in_qty, transfer_id))

四、定时对账:流水为准,差异自动补偿

只靠实时链路仍会有边角不一致(消息丢了、人工在后台直接改了数)。我们让每一次库存变动都落一条不可变流水,再用定时任务把"流水累加"和"当前台账"逐店逐 SKU 对账:

代码语言:python
复制
def reconcile(store_id, sku_id):
    # 流水重放得到应有库存:期初 + 入库 + 调拨入 - 销售 - 调拨出 - 盘亏
    ledger = db.fetchone("""
      SELECT begin_qty
           + SUM(CASE WHEN type IN('SALE_IN','TRANSFER_IN') THEN qty ELSE 0 END)
           - SUM(CASE WHEN type IN('SALE_OUT','TRANSFER_OUT','LOSS') THEN qty ELSE 0 END) AS expect
      FROM stock_flow
      WHERE store_id=%s AND sku_id=%s
    """, (store_id, sku_id))
    actual = db.fetchone("SELECT onhand FROM store_stock WHERE store_id=%s AND sku_id=%s",
                         (store_id, sku_id))
    if ledger.expect != actual.onhand:
        # 差异不直接改库存,先落差异单并告警,由值班确认后补偿
        db.execute("INSERT INTO stock_diff(store_id,sku_id,expect_qty,actual_qty,created_at) "
                   "VALUES(%s,%s,%s,%s,NOW())",
                   (store_id, sku_id, ledger.expect, actual.onhand))
        alert(f"库存差异 门店{store_id} SKU{sku_id} 应{ledger.expect} 实{actual.onhand}")

上线后我们用 15 家门店、约 8000 个 SKU 跑了一个月:日均跨店订单峰值到 2000 单/小时,调拨同步延迟从最初的平均 15 分钟压到 3 秒内,库存差异工单从每天 30 多条降到个位数,且都能靠流水在 10 分钟内定位到具体单据。

五、踩坑清单

  • 坑1:可售量直接相加:把各店 onhand 加总当共享可售,没扣锁定和在途,必然超卖;可售永远用 onhand - locked - 在途 实时算。
  • 坑2:先查后扣非原子:高并发下"查询够→再扣减"两步之间会被插队;锁定用原子脚本或带条件的 UPDATE ... WHERE onhand-locked>=qty
  • 坑3:调拨两头记账:出库就给 A 店加库存,在途这段被重复计算;务必出库只转在途、到货按实收才入 A 店。
  • 坑4:按单调数字强行入库:实际少货也凑平,差异被藏进台账;以 real_in_qty 入库,差额走差异单。
  • 坑5:只信实时不做对账:消息丢失或人工后台改数无法发现;库存流水不可变,定时重放对账,差异先告警再补偿。

六、一次大促的验证

方案上线后赶上一次区域大促,三天跨店履约 4.6 万单、调拨 1200 多次,峰值 2300 单/小时。结果:下单锁定成功率 99.97%,未出现一例超卖;在途库存口径全程一致,活动结束后逐店盘点,系统台账与实物的差异 SKU 仅 3 个,且都是门店手工搬动未走调拨单所致,补上单据即平。

结语

多门店库存对不上,本质是"一个共享可售量"背后其实挂着实物、锁定、在途、流水四套账,只用其中一套必然露馅。原子锁定解决"能不能卖",调拨状态机解决"在途算谁的",流水对账解决"长期会不会漂"。连锁库存的可靠性,说到底是让每一件货在任何时刻都只有一个权威状态,其余都是这个状态的派生视图。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 导读
  • 一、为什么多门店库存必然对不上
  • 二、共享可售量:先把"锁定"和"在途"扣干净
  • 三、调拨在途:出库先扣、到货确认、差异回滚
  • 四、定时对账:流水为准,差异自动补偿
  • 五、踩坑清单
  • 六、一次大促的验证
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档