深色
广播量公式 + 库存池(先卖先扣)
All migrations through 0028 exist, confirming the schema supports all four formula columns plus awaiting_payment and consumed_qty. I have everything I need. Here is my deep-dive analysis.
广播量公式 + 库存池「先卖先扣」端到端深挖
一、这是什么、在哪里实作
核心文件:cc-wms/src/services/ledger.ts(纯计算 + 重算调度) 纯公式函式:computeLedgerUpdate()(ledger.ts:40-64) 取数函式:cc-wms/src/db/ledger.ts(pending)+ cc-wms/src/db/shipment-reservations.ts(dc_reserved) 退场(核销):cc-wms/src/services/settle.ts真正写平台的咽喉点:cc-wms/src/services/platform-push.ts 的 executePlatformPush()对应 ADR:ADR-029(safety_buffer 预设 0)、ADR-030(Delta Sync)、ADR-036(空系统禁推)、ADR-040(dc_reserved 入公式 + 预留生命周期)、ADR-043(FIFO 额度消费)、ADR-045(fba_deduct 退场)、ADR-047(dc_reserved 含 draft 的最终定案)、ADR-049(SF Pending 即时占池)。
二、广播量公式(铁则第 3 条)
broadcast_qty = max(0, wms_physical − pending_orders − dc_reserved − safety_buffer)实作(ledger.ts:41-44):
ts
const broadcastQty = Math.max(0,
input.wmsPhysical - input.pendingOrders - input.dcReserved - input.safetyBuffer)四个减项的精确定义(每项都有独立 SQL 来源,三处实作用测试锁死一致,见 ledger.test.ts:222-289):
| 项 | 含义 | SQL 来源(精确条件) |
|---|---|---|
wms_physical | 仓库实体库存(最近一次真实 xlsx 上传的快照) | wms_stock 按 import_id SUM;import_id 只认 source IN ('manual_upload','manual_force') AND status IN ('success','partial')(db/ledger.ts:16-25) |
pending_orders | 已收单未结算的客户订单 | orders 表 status IN ('new','batched','shipped') AND settled_at IS NULL,且排除真 FBA 销售单(db/ledger.ts:49-59) |
dc_reserved | DC 补货出货单预留(送 Takealot 仓的货) | shipment_reservations 表 item_type='replenishment' AND cancelled=0 AND status='active'(含 draft)(shipment-reservations.ts:124-133) |
safety_buffer | 每个 SKU 手动保留量 | ledger.safety_buffer,预设 0,从 DB 读、绝不写死、UPDATE 时不覆写(保留操作员设定) |
数学验证(ledger.test.ts 实测全绿):100 物理 − 10 待发 − 0 DC − 1 缓冲 = 89;5 物理 − 10 待发 = max(0, −6) = 0(防负 = 防超卖)。
为什么是「减法」而不是直接用实体库存?
WMS 是「物理真实层」,但它靠 PDA 人工扫码扣减,有数小时延迟。在这段延迟里,平台已经把货卖掉了(pending_orders),DC 补货的货已经在卡车上了(dc_reserved),但 WMS 快照还没反映。如果直接把 wms_physical 广播给平台 = 把「已经答应别人的货」再卖一次 = 超卖。公式的本质:广播量 = 物理库存里「还没被任何人预订」的剩余部分。
三、库存池「先卖先扣」的状态机
「池」(Pool) = 同一批 WMS 实体库存,重复曝光给 Takealot / Amazon / Makro 三个平台。谁先卖,谁先从 wms_physical 扣(透过加大 pending_orders 或 dc_reserved),剩下的 broadcast_qty 自动变小,再广播给所有平台 → 别的平台看到的可售量同步下降。
进场(占池)— 订单状态机
订单生命周期 CHECK 约束(migration 0021/0028):new → batched → shipped → settled,旁支 cancelled / pending_mapping / payment_pending。
下单 ──→ new ──→ batched(备货中) ──→ shipped(已出库) ──→ settled(已完成,退池)
│ └─ 全程 settled_at IS NULL → 计入 pending_orders → 压低 broadcast
└─ 对不到 SKU 映射 → pending_mapping(不乱扣,补映射后转 new)关键设计点(与超卖直接相关):
下单即占池(不等付款)。
insertOrder(db/orders.ts:35-80)一收单就写status='new',立刻进入 pending。Amazon SF(FLEX)单 ADR-049 特别处理:SF 单在 Amazon API 长期停留Pending,过去被跳过 → 每天约 20 件、最长 30 小时的隐形超卖窗 → 现在 Pending 阶段就以awaiting_payment=1占池(仍计 pending,只是 UI 分到「付款待确认」页)。shipped(已出货)仍然占池。这是反直觉但正确的:货虽然出了,但
wms_physical快照还没更新(要等下次 xlsx 上传)。如果出货当下就退池,会出现「实体没降但广播先升」的空窗 = 超卖。所以markOrderShipped(db/orders.ts:229-242)只记shipped_at,不触发重算,预留撑到下次上传。
退场(退池)— settle 五条路径
settleReservations(settle.ts:24-79)只在 WMS 上传成功后调用。原理:上传 = 重新校准基准,新 wms_physical 已经反映了「已出库」的扣减,此时才安全退池(否则帐面没降就先放 = 超卖)。
| 路径 | 谁 | 信号 | 代码位置 |
|---|---|---|---|
| A | DC 补货明细 | shipped=1(快递已取走)→ status='settled' | settle.ts:26-32 |
| B | Takealot leadtime 客户单 | 出货单 customer_order 明细 shipped=1,按 wms_code FIFO 退最旧订单 | settle.ts:91-188 |
| C | Amazon/Makro leadtime | 订单 status='shipped'(排除 takealot) | settle.ts:38-43 |
| D | fba_deduct 虚拟扣减单 | 下次全量上传无条件退(ADR-045) | settle.ts:50-55 |
| E | TTL 保险阀 | Takealot shipped 单超 7 天还没退 → 无条件退(接漏网) | settle.ts:65-71 |
FIFO(路径 B)的守恒不变式(ADR-043,防超卖核心):
累计核销的 Takealot 订单量 ≤ 累计 confirmed 出货量
实作用 consumed_qty(migration 0019)做「额度部分消费」:出货确认了多少件,就按最旧订单优先消费多少额度,吃满整行才标 settled,剩余额度 carry-forward 到下次上传再凑。退单 + 消费放同一个 db.batch(D1 原子交易),不可只成功一半。
四、Delta Sync —— 公式算完之后怎么推
算出新 broadcast_qty 后,并非全量推送(13,000+ SKU × 3 平台 = 数万次 API 呼叫,必撞 rate limit)。ADR-030 规定 Delta Sync:只推 broadcast_qty 有变动的 SKU。
recalculateLedgerFull(ledger.ts:76-181):
- 先快照旧
broadcast_qty(oldMap) - 一条
INSERT…SELECT…UPSERT把 17k SKU 全部重算(取代 68,340 次顺序查询) - 处理「本次上传消失的 SKU」→ 归零下架(ledger.ts:136-146,ADR P0-1:不处理的话旧 broadcast 永久残留 = 卖已不存在的货)
- diff 新旧 → 只把有变动且未冻结的 SKU enqueue
PLATFORM_PUSH
冻结(frozen):冻结的 SKU 永远保留旧 broadcast、绝不推(手动锁量的逃生舱)。 推送模式(ADR-037):auto = 即时推;manual = 只算不推,变动累积成待推清单等操作员手动 flush。
五、它在防止什么错误(防御层次总表)
| 防御 | 防什么 | 代码 |
|---|---|---|
max(0, …) | pending 超过实体时广播负数 → 推成负库存 | ledger.ts:41 |
| 公式四项不可省 | 漏 dc_reserved → DC 补货的货被重复卖(铁则 3) | ledger.test.ts 三处一致性锁 |
FBA 排除用 NULL-safe IS | variant='FBA' 对 NULL 求值为 NULL → 三平台 pending 整列被丢 → 广播虚高超卖 | db/ledger.ts:46-54 |
| shipped 仍占池 | 出货到上传之间的空窗超卖 | db/orders.ts:226-242 |
| settle 只在上传时 | 帐面没降就先放 → 超卖 | settle.ts:23 |
| FIFO 守恒不变式 | 已核销 ≤ 已确认出货;幽灵额度提前核销新单 | settle.ts:157-161 + ADR-043 |
| 消失 SKU 归零 | 停售商品旧 broadcast 残留继续广播 | ledger.ts:136-146 |
| ADR-036 空系统禁推 | 还没上传库存时全算 0 → 把平台 listing 归零/下架 | platform-push.ts:108-111 |
| ADR-035 全局只读 | 沙盒测试时绝不写平台 | platform-push.ts:116-131 |
| 批次失败保护 | 失败率 > 20% 自动停推 + 告警 | platform-push.ts:199-204 |
六、与 6/21 真实写入相关的正确性风险(重点)
6/21 是第一次真实推送虚拟库存到线上平台,这条「算 broadcast → executePlatformPush → 写平台 API」的链路第一次真的打到 Takealot/Amazon/Makro 的写库存接口。风险点:
🔴 风险 1:上线顺序铁律 —— 不上传库存就推送 = listing 全部归零
这是最致命的风险。ADR-036 的逻辑:若 getLatestImportId(db) === null(从没成功上传过 xlsx),公式 max(0, 0 − pending − buffer) 把每个 SKU 都算成 0;订单驱动的 targeted recalc 又把全新 SKU 当「有变动」→ shouldPush=true → 会把 0 推给平台 = 全站 listing 库存归零/下架。而 cron 每 5 分钟自动跑,无需人工触发即可酿灾。 护栏已就位(platform-push.ts:108-111 硬闸,独立于只读模式)。给 6/21 的操作铁律:必须先成功上传一次真实 WMS xlsx,确认 wms_physical 有数,才解除安全模式开始推送。
🔴 风险 2:只读模式(安全模式)必须在验证完才关
isGlobalReadOnly(platform-push.ts:116-131)是唯一咽喉点。开着时只写 push_log 不打平台 API。6/21 流程建议:先在只读模式下让系统跑一轮,看 push_log 里「本来会推什么数字」是否合理(人工核对几个 SKU),确认无误再关只读放行真实写入。 这是 ADR-035 设计的全部目的。
🟠 风险 3:dc_reserved 含 draft → confirm 到上传之间有「已接受的少卖窗」
ADR-047 最终定案:因为 Takealot API 分不出草稿/已确认(草稿建立起 po_state 就 = 'shipped'),dc_reserved 从 draft 起就扣。代价(Nate 已拍板接受):DC 补货长期停在 draft 会压低 broadcast(少卖方向,安全,非超卖)。6/21 当天若发现某些 SKU 广播数比预期低,先查是不是 draft 补货单压着,按「强制同步」全量重算可立即释放。这是预期行为,不是 bug。
🟠 风险 4:FIFO 退场依赖「shipped 旗标」的新鲜度
路径 B 用 shipped=1 判离仓。这个旗标靠 shipment-sync 的「离场回查」维持新鲜(货被取走后出货单离开 shipped=false 镜像,需点名重拉)。若离场回查没跟上,shipped 旗标停在 0 → 额度不来 → 订单卡 pending(少卖方向,且有 7 天 TTL 兜底)。6/21 主要测推送,这条短期不致命,但上线后要观察 settle 是否正常退场。
🟢 已修复(不再是风险):orders CHECK 不含 'DC'
记忆里提过的「orders CHECK 不含 'DC' 既有缺陷」,已由 migration 0021 修复(variant CHECK 现含 'FBM','FBA','FLEX','DC',已确认存在于 migrations 目录)。DC 虚拟单插入不再被拒。
6/21 的方向性结论
公式的所有「方向」都偏保守(宁可少卖不超卖):max(0) 防负、shipped 仍占池、settle 只在上传时退、FIFO 守恒、空系统禁推。6/21 主要的真实风险不在公式数学(数学已被 30+ 测试锁死),而在「操作顺序」:先上传库存、先只读验证、再放行。 公式本身算错导致超卖的概率很低;最大的现实风险是「没上传就推 → 归零」和「忘了关只读 → 什么都没推还以为成功」这两个流程问题。
七、给非技术读者的白话总结
这套系统在干嘛? 你的仓库里有一批货,要同时挂在 Takealot、Amazon、Makro 三个平台卖。问题是:同一批货只能卖一次,但挂在三个地方,万一三个平台同时各卖一件,仓库只有两件 → 超卖、要取消订单、被平台扣分。
这条公式就是防超卖的总闸门:
「能挂出去卖的数量 = 仓库实有 − 已经卖掉还没出货的 − 送去 DC 仓的补货 − 你想留底不卖的」 算出来如果是负数,就当 0(绝不挂负库存)。
「先卖先扣」是什么意思? 三个平台共用同一批货。哪个平台先卖出一件,系统立刻把这件从「可卖数」里扣掉,然后把变小后的数字同步推给所有平台。所以别的平台马上就看到「少了一件」,不会重复卖。
为什么货出了还不马上「还回去」? 因为你的仓库数字是人工扫码更新的,有几个小时延迟。货刚出门、但仓库系统还没扣,这时候如果系统提前把数字加回去,就会凭空多卖。所以系统的规矩是:等你下次上传仓库最新库存表,确认数字降了,才把那批货正式销帐。 这是故意「宁慢勿错」。
6/21 第一次真的去改线上平台库存,最该小心三件事:
- 先上传一次真实库存表再开始推。 系统有硬保险:没上传过库存就推送,会把所有商品库存推成 0、等于全部下架。(保险已经装好,但顺序不能错。)
- 先开「只读/安全模式」跑一轮看看。 这个模式下系统只「记录本来会推什么数字」但不真的改平台。你核对几个商品的数字对不对,确认无误,再关掉安全模式放行。
- 有些商品广播数字可能偏低,多半是 DC 补货单压着(货要送 DC 仓),这是安全的少卖、不是错。 按「强制同步」重算一次就会自动调整。
一句话:公式的数学本身很稳(几十个自动测试盯着),6/21 真正要守的是操作顺序 —— 先上传、先只读验证、再放行真实写入。
关键文件路径(供整理教学网站引用):
- 公式与重算:
C:\Users\langl\Downloads\CC WMS\cc-wms\src\services\ledger.ts - 取数(pending):
C:\Users\langl\Downloads\CC WMS\cc-wms\src\db\ledger.ts - 取数(dc_reserved)+ 预留镜像:
C:\Users\langl\Downloads\CC WMS\cc-wms\src\db\shipment-reservations.ts - 订单状态机:
C:\Users\langl\Downloads\CC WMS\cc-wms\src\db\orders.ts - 退场/核销五路径:
C:\Users\langl\Downloads\CC WMS\cc-wms\src\services\settle.ts - 真实写平台咽喉点(含两道硬闸):
C:\Users\langl\Downloads\CC WMS\cc-wms\src\services\platform-push.ts - 公式测试(数学验证 + 三处一致性锁):
C:\Users\langl\Downloads\CC WMS\cc-wms\src\services\ledger.test.ts - 决策记录:
C:\Users\langl\Downloads\CC WMS\docs\13-決策記錄.md(ADR-029/030/036/040/043/045/047/049) - DC 变体修复:
C:\Users\langl\Downloads\CC WMS\cc-wms\migrations\0021_orders_variant_dc.sql