Skip to content

每日对账「两个时钟」+ 预留生命周期

I have a complete, code-grounded picture. runCloudRecon is triggered manually (a button posting type:'recon' to /api/bridge/...), confirming the "operator runs it on demand" premise behind the two-clocks problem. I have all the implementation, the ADRs, the tests, and the rebaseline incident. Here is the deep-dive.


核心原理深挖:每日对账「两个时钟」+ reserved 预留生命周期(ADR-040 / ADR-052)

关键源文件(皆绝对路径):

  • C:\Users\langl\Downloads\CC WMS\cc-wms\src\services\bridge-recon.ts(每日对账引擎,913 行)
  • C:\Users\langl\Downloads\CC WMS\cc-wms\src\services\settle.ts(预留退场状态机)
  • C:\Users\langl\Downloads\CC WMS\cc-wms\src\db\shipment-reservations.ts(预留镜像同步)
  • C:\Users\langl\Downloads\CC WMS\cc-wms\src\services\ledger.ts(铁则3 broadcast 公式)
  • C:\Users\langl\Downloads\CC WMS\cc-wms\src\routes\recon.ts...\src\routes\bridge.ts(触发入口)
  • ADR-040/052/043/045/047/054:C:\Users\langl\Downloads\CC WMS\docs\13-決策記錄.md

第一部分:两个「时钟」到底是什么

1.1 对账要做的事(守恒式)

每日对账的核心是一条库存守恒方程bridge-recon.ts:15、ADR-052),逐 SKU 和逐全仓都要成立:

今日快照(q1) − 昨日快照(q0)  =  −撿货出库(pickOut) + 入库(inbound) + 残差(resid)

程式实作在 computeReconbridge-recon.ts:244):

const resid = delta + pickOut - inbound      // delta = q1 - q0

残差 resid 不是 0 的那些 SKU,就是要给 Nate 看的东西:他的盘点修正、损坏取消、真正的异常(盘亏/拣错/漏单据)。残差 = 0 的 SKU 直接跳过(if (resid === 0) continue)。

白话:左边是「库存数字少了多少」,右边是「我能解释的部分(出货、进货)」,对不上的就叫残差,残差要嘛能归因、要嘛是异常。

1.2 两个时钟为什么会错开

这套方程里有两个独立推进的「时间游标」,它们各自走各自的步调——这就是「两个时钟」:

时钟是什么何时前进程式依据
快照时钟(snapshot clock)q1 − q0,两次完整库存上传的差每次操作员手动上传 xlsx 就多一笔 wms_importswms_imports 表,runCloudRecon 取 t0/t1
撿货时钟(pick clock)pickOut,撿货单已撿量(yetNum)的「增量」只在每次跑对账时,把当前撿货量减去「上次对账时记下的撿货量」KEY_RECON_PICK_STATE(存在 system_config),computePickWindow bridge-recon.ts:154

关键差异(bridge-recon.ts:80-83 注释明写):

  • 撿货出库是「消耗性的 Δ」computePickWindowMath.max(0, nowQty - (before[code] ?? 0))bridge-recon.ts:168),算的是「自上次对账基准以来」新撿出的量。这个基准(prevState只在每次对账成功时才往前推bridge-recon.ts:837 才写回 KEY_RECON_PICK_STATE)。
  • 快照 delta 是「绝对差」:t1 − t0,跟着「上传」走,不跟着「对账」走。

如果 t0 取「最近第二笔上传」(旧的错误做法),那么当操作员上传了好几次、但中间只对账一次时:

  • 快照 delta 只覆盖「最近两次上传之间」这一小段(比方 06/14 → 06/15)
  • 但 pickOut 覆盖「上次对账 → 现在」一整段(比方 06/12 → 06/15)
  • pickOut 里含有「孤儿区间」(06/12→06/14)的撿货量,而 delta 不含那段的库存变化

结果:resid = delta + pickOut − inbound 里 pickOut 多算了一截,凭空冒出残差(假警报)。注释原文:「pickOut 会含『孤儿区间』撿货而 delta 不含 → 凭空残差」。

1.3 修法:把两个时钟锚在同一个起点

修复函数 reconAnchorT0Idbridge-recon.ts:85-91,2026-06-14 修,已部署 commit ce5396d / version fb5177be,见专案记忆「每日對賬兩時鐘假殘差」):

ts
export function reconAnchorT0Id(lastRunPair: string | null | undefined, latestId: number): number | null {
  if (!lastRunPair) return null                              // 首次对账 / 无前次
  const lastT1 = parseInt(lastRunPair.split('-')[1] ?? '', 10)  // 上次对账的 t1(结束快照)
  if (!Number.isFinite(lastT1)) return null
  if (lastT1 >= latestId) return null                        // 无新上传 → 退回
  return lastT1
}

核心思想:t0 不再取「最近第二笔上传」,而是锚在**「上次对账结束的那次快照」**(lastRun.pair 的 t1)。这样 delta 和 pickOut 永远涵盖同一段 [上次对账 → 最新],不管中间上传了几次。

数学上:

  • 设上次对账配对是 pair = "3-4"(对到 import #4 结束)
  • 现在最新上传是 #6(中间 #5、#6 都没对过账)
  • 旧做法 t0 = #5(最近第二笔)→ delta 只覆盖 #5→#6,pickOut 覆盖 #4→#6 ❌ 错开
  • 新做法 t0 = reconAnchorT0Id("3-4", 6) = 4 → delta 覆盖 #4→#6,pickOut 也覆盖 #4→#6 ✅ 对齐

测试锁死(bridge-recon.test.ts:302-320):

reconAnchorT0Id('3-4', 5) → 4   // 上次对到 #4、现最新 #5 → t0=#4
reconAnchorT0Id('3-4', 6) → 4   // 上次对到 #4、其后 #5/#6 都没对 → t0 仍=#4(关键:跨多次上传仍对齐)
reconAnchorT0Id('3-4', 4) → null // 无新上传 → 退回(由幂等锁处理)
reconAnchorT0Id('4-', 5)  → null // 脏数据 → 退回

runCloudRecon 里的落地(bridge-recon.ts:579-602):先用 anchorId 去查那笔快照;若锚点查不到(首次 / 被 18 个月保留政策清掉 / 无新上传),安全退回到「上一笔成功上传」(id < latestOk.id 的最新一笔)。

1.4 三道纵深防御(防假残差)

「两个时钟」只是其中一道。整个对账引擎对「假残差」有三层防护,理解它们才知道哪些情况会出错:

① 同窗幂等锁KEY_RECON_LASTbridge-recon.ts:606-614): 撿货 Δ 是消耗性的——同一对快照重复跑,第二次时 pickOut 会被算成 0(因为基准已被第一次推进到当前值),守恒式立刻崩。所以同一对 pairKey${t0.id}-${t1.id})第二次点,直接回上次报告,不重算。

② 假报告自我怀疑 looksLikeStaleRerunbridge-recon.ts:75-77):

ts
return residRowCount > 30 && totalPickOut === 0

「库存大量变动但撿货差量全为 0」= 几乎必是「状态已被上次消耗」或「撿货单没导入」。命中时:报告自承无效 + 跳过全部状态写入bridge-recon.ts:832-840,不污染观察名单)+ 不生成档案。

  • 测试(bridge-recon.test.ts:292-298):looksLikeStaleRerun(191, 0) → true(6/12 实证 191 行假残差事故);(5, 0) → false(假日无撿货+少量国内调整是合法的);(191, 239) → false(有撿货差量=正常大流量日)。

③ 48h carry-forward 宽限CARRY_AGE_MS = 48hbridge-recon.ts:267-274): 新残差先黄灯观察,跨两天没冲回才转红灯——防「狼来了」(迟到的出货信号通常会自动冲回)。


第二部分:reserved 预留生命周期(ADR-040 / 047 / 043)

2.1 为什么需要 reserved——铁则第 3 条

广播公式(ledger.ts:40-44,铁则3,CLAUDE.md 明令不可简化):

ts
broadcastQty = Math.max(0, wmsPhysical − pendingOrders − dcReserved − safetyBuffer)

其中 dcReserved 是 ADR-040(2026-06)新增的第 4 项。它防的是一个具体灾难:DC 补货的货被重复卖出

场景:操作员做了一张「补 79 件去 Takealot DC 仓」的出货单。这批货即将离开本地仓,但 WMS 物理库存(下次上传前)还显示 79 件都在。如果不预扣,CC WMS 会把这 79 件继续广播给各平台当可卖库存 → 客户下单 → 但货已经在去 DC 的卡车上 → 超卖

所以 reserved 是一个「预占」:货还没物理离仓、但已经承诺出去的量,先从可广播量里扣掉。

2.2 reserved 的状态机(镜像式,非流水帐)

预留不是记流水(reserve 一笔、settle 一笔),而是**「镜像」Takealot 后台的出货单**(ADR-040 §5②,shipment-reservations.ts:17 注释)。每 5 分钟 syncReservationSnapshot 把 Takealot /shipments 的当前状态镜像到 shipment_reservations 表。

状态流转:

                  Takealot /shipments 出现该明细

          ┌───────────────────▼────────────────────┐
          │  INSERT: status='active', cancelled=0   │  ← 草稿一出现就锁
          │  (从建立起就预扣库存)                    │
          └───────────────────┬────────────────────┘

        ┌─────────────────────┼─────────────────────┐
        ▼                     ▼                     ▼
  草稿被移除/整张消失      快递取走(shipped=1)        被取消(cancelled=1)
  (本轮镜像没见到)            │                     │
        │                     │                     │
   prune → cancelled=1   等下次 WMS 上传        prune → cancelled=1
   (自动释放)                │                  (自动释放)

                     settle(WMS 上传时)
                     → status='settled'
                       或 consumed_qty 累加

两个关键设计点:

(A) 「草稿就锁」而非「确认才锁」(ADR-047 同日修正,commit 4737131,prod 实证 143873):

  • 本来想做「confirmed 才扣」,但 Takealot API 根本分不出草稿和已确认——草稿出货单从建立那一刻 purchase_order_state 就已经 = 'shipped'(官方文档范例也是 shipped:falsepo_state:'shipped' 并存,通篇无 draft 概念)。
  • 而且 confirm(撿货完成离仓)→ 下次上传之间,货已经在卡车上无法再让位给订单 → 这段必须有预留挡着,否则是真超卖窗。
  • 结论:dcReserved 含 draftshipment-reservations.ts:124getDcReservedQtyledger.ts:115 的全量 SQL 两处条件必须一致:item_type='replenishment' AND cancelled=0 AND status='active'过滤 po_state)。

(B) 退场判别 = shipped 旗标,绝不用 po_statesettle.ts:17-23 大段警告):

  • 如果用 po_state 当「离仓」信号,会把整张未撿货的草稿误核销(因为草稿生来 po_state 就 = shipped)→ 之后真出货时无任何预留挡 → 超卖settle.ts:18 注释点名 8150704 是既往误核销痕迹。
  • 正确信号是 shipped=1(快递真的取走了)。由 shipment-sync 的「离场回查」(ADR-047 ④)维持新鲜:被取走的单会永远离开 shipped=false 镜像,所以要按 shipment_id__in 点名重拉 active 预留的出货单,让旗标跟上现实。

2.3 settle 的五条退场路径(settle.ts:24-79

settleReservations每次 WMS 上传成功后呼叫。原理(settle.ts:8-9):WMS 上传 = 重新校准基准,新 wms_physical 已反映「已出库」,此时才安全释放已离仓的预留。释放只在这里发生——绝不在帐面还没降的时候提前放,避免超卖空窗。

路径对象触发条件SQL 位置
ADC 补货明细(replenishment)shipped=1status='settled'settle.ts:26-32
CAmazon/Makro leadtime 订单status='shipped'platform <> 'takealot' → 写 settled_atsettle.ts:38-43
Dfba_deduct_* 虚拟扣减单platform_order_id LIKE 'fba_deduct_%' → 写 settled_at(ADR-045)settle.ts:50-55
BTakealot leadtime(FIFO 额度消费)confirmed customer_order 明细 shipped=1,按 wms_code 先进先出退最旧订单settle.ts:91settleLeadtimeFifo
ETTL 保险阀Takealot shipped 单超 7 天没退 → 无条件 settle(ADR-043 follow-up)settle.ts:65-71

2.4 路径 B 的精妙之处:FIFO「部分额度消费」(ADR-043,P0-2 修复)

这是整个预留系统最容易出错、也是防超卖的守恒核心。

为什么 Takealot 不走路径 C(status='shipped' 直接清)?settle.ts:35-37) 因为 Takealot leadtime 的唯一退场通道必须是 FIFO 额度消费。如果 shipped 的 Takealot 单也从路径 C 核销,它的出货单 confirmed 额度就永不被消费 → carry-forward 下「幽灵额度」逐轮累积 → 把未出货的新单提早核销 → broadcast 虚高 = 超卖方向

部分消费机制settle.ts:91-188,migration 0019 加了 consumed_qty 栏):

  1. 算每个 wms_code「已确认且尚未消费」的待退量:SUM(MAX(0, quantity - consumed_qty)),只看 shipped=1settle.ts:95-99
  2. 撈该 code 最旧的未结算 Takealot leadtime 订单(ORDER BY ordered_at ASC, id ASC
  3. FIFO 整单累加,累计「不超过」ship_qty 才退if (acc + o.qty > ship_qty) breaksettle.ts:125)——保守,宁可少退不超卖
  4. 把退掉的量按最旧明细行优先摊销 consumed_qty行吃满(consumed_qty >= quantity)才标 settled,没吃满保持 active,残余额度 carry-forward 到下次上传再凑

守恒不变式(ADR-043 防超卖的核心):累计核销的 Takealot 订单量 ≤ 累计 confirmed 出货量。

原子性守卫settle.ts:157-161):重读后的额度若覆盖不了 acc(并发 settle / prune / 镜像改小落在两次 SELECT 之间)→ 本轮放弃这个 wms_code,一张都不退。退单 UPDATE + 消费 UPDATE 放同一个 db.batch(D1 单一交易),消费用相对加法 consumed_qty = consumed_qty + ? + WHERE status='active',容忍与 poller 镜像同步并发(极端只少消费不多消费,方向安全)。

旧 bug(修复前):每轮把全部 confirmed 明细无条件标 settled,FIFO 没用到的确认量被烧掉且永不补发 → 确认量小于最旧订单时该单永远凑不齐 → 永久卡 pending、库存锁死(少卖 + 帐面永久 diff)。

2.5 对账「上界解释」——预留如何回到对账(防双重抵扣)

这里把两个主题接起来了。对账时,Takealot 每日批次不走撿货模组(走预留轨),所以它造成的库存下降不能拿撿货量去抵——否则 PO 补货单两边都算,双重抵扣(ADR-052 ③、ADR-054、ADR-057)。

computeReconbridge-recon.ts:253-265)对负残差(库存少了)先尝试用「上界解释」:

ts
const evidence = settledTakealot + pendingTakealot + reservedTakealot
if (evidence >= -resid) {
  bucket = 'Takealot 出货/占用(上界解释)'
  // 「占用 X + 预留 Y + 核销 Z ≥ 缺口」→ 这缺口是 Takealot 批次出货造成的,可解释
}

注意是 (上界)而非相减——这是刻意的。reservedTkl 来自 shipment_reservations 的 active+未取消量(bridge-recon.ts:702-708)。这一项就是把「预留生命周期」的当前状态,作为「这部分库存下降是合理的」的证据,而不是再扣一次。


第三部分:与 6/21 真实写入相关的正确性风险

6/21 是第一次真实写入线上平台(推送虚拟库存 + 线上比价改价)。这两个系统直接决定「广播给平台的数字对不对」,下面是忠于程式码的风险盘点:

🔴 风险 1:reserved 退场依赖 shipped 旗标的「离场回查」,但回查有张数上限

settle.ts 释放预留只认 shipped=1。这个旗标的新鲜度靠 shipment-sync 的「离场回查」(按 shipment_id__in 点名重拉,ADR-047 ④,上限 ≤100 张)。若某帐号同时有 >100 张 active 预留的出货单,超出的那批旗标可能没及时更新 → 预留迟迟不释放 → broadcast 偏低(少卖,安全方向,非超卖)。这是安全侧风险,但 6/21 真实推送时会表现为「某些 SKU 广播数字偏保守」,需知道这是设计使然不是 bug。

🟡 风险 2:/sales sale_status 字符串未 100% 校正(ADR-040 唯一未确认点)

ADR-040 明记:唯一未确认资料点是 /sales 已出货 sale_status 字符串。专案记忆「Takealot sale_status 8 種」已实证至今 8 种状态全是出货阶段、无付款类,且连接器白名单未知状态走 ignore + audit('order_status_unknown')(不扣库存,保守)。风险点:6/21 后若 Takealot 出现新的 sale_status,会被 ignore 而不是入预留 → 可能漏锁某单 → 理论超卖窗(同 ADR-049 类型)。守法已内建:stuckMapping 警报(bridge-recon.ts:721-732)+ order_status_unknown 稽核。建议 6/21 当天跑一次源头体检(ADR-057 的 probeSales)。

🟡 风险 3:两个时钟修复只在「按对账钮」时生效——多日不对账仍会卡死

runCloudRecon手动触发bridge.ts:190,操作员按「数据导入→自动同步→对账」),不是 cron 自动跑。两个时钟的锚定修复解决了「多次上传只对账一次」的假残差,但有一个它不能解决的次生问题(专案记忆「每日對賬兩時鐘假殘差」➕2026-06-15 段已实证):

  • 多日不对账 → 撿货单在旧 WMS 里滚动归档(撿货时钟的历史拿不回来)→ totalPickOut=0 stale → looksLikeStaleRerun 命中 → 报告自承无效、卡死循环。
  • 这正是 6/15 发生的:上次真对账是 6/12(pair 3-4),拖到 6/15 才对,撿货单已归档无法重建 → 只能手动 rebaselinedocs/audit/recon-rebaseline-20260615.sql:DB 直接把 bridge_recon_last 改成 pair:"5-6" 锚到最新上传 #6 + pick_state 清空,audit 56674)。

对 6/21 的含义:对账系统的运维铁则是「上传当天就对账(≈1:1)」。如果 6/21 上线测试期间连续上传却不及时对账,对账会再次卡 stale。这不影响平台推送的正确性(broadcast 公式独立运作),但会让「每日对账」这道验证手段失效——而 6/21 恰恰最需要它来确认推送数字对不对。建议:6/21 每次上传后立刻对账。

🟡 风险 4:对账的「订单vs出货」跨日追平,SF 常态时间差长达 72h

computeOrderMatchbridge-recon.ts:382)对 Amazon SF 有 72h(CHASE_AGE_MS)追平宽限——SF 撿货日与核销日常态差 1 天、跨周末 3 天。6/21 是周六。这意味着 6/21 当天对账时,SF 单的「订单核销 vs 实撿」大概率显示「⏳追平中」而不是「✅对上」,这是正常的(bridge-recon.ts:453),不是漏单。读对账报告的人要知道周末的 SF 不平是预期内的。

🟢 风险 5(已封堵,列出供安心):空系统误推 0

ADR-036 硬保险:从未成功上传 WMS 库存时 executePlatformPush 一律禁止推送(否则 max(0, 0−pending−buffer) 全算 0 → 把 0 推给平台 = listing 归零/下架)。这条独立于只读模式(ADR-035)。6/21 只要确认已有成功上传,此风险已封。另 ADR-035 全局只读开关是「拿真 key 进沙盒不写平台」的总保险。

🟢 风险 6(已封堵):pending 口径与铁则一致

广播公式的 pending(ledger.ts:108-110)和单 SKU 查询(db/ledger.ts:53-54)口径完全一致:status IN ('new','batched','shipped') AND settled_at IS NULL AND NOT (variant IS 'FBA' AND platform_order_id NOT LIKE 'fba_deduct_%')。真 FBA 销售单(货在亚马逊仓)正确排除在 pending 之外、但 fba_deduct 虚拟单计入——口径自洽。


给非技术读者的白话总结(写给 Nate)

「两个时钟」是什么? 每天对账要算一道账:「今天库存比上次少了多少」要等于「卖出去/送走的 + 进货的 + 对不上的(残差)」。这里有两个「计时器」各走各的——一个跟着你上传库存表走,一个跟着你按对账钮走。如果你上传了好几次、却只对了一次账,两个计时器就会错开,账面会冒出根本不存在的「少货」假警报。我们已经修好了(2026-06-14 上线):让两个计时器永远从「上次对账结束的那一刻」一起起跑,不管中间你传了几次。

「预留」是什么? 当你做一张「送 79 件去 Takealot DC 仓」的单,这批货虽然还在你仓里(库存表还没更新),但已经承诺出去了。系统会先把这 79 件从「可卖数量」里扣掉,免得平台还把它当现货卖、客人下了单货却已经在路上——那就是超卖。这个「先扣起来」就叫预留。预留从你建草稿单的那一刻就锁住(因为 Takealot 的系统分不出草稿和正式单),等到你下次上传库存表、确认货真的离仓了,才把预留解开。

对 6/21 上线测试最重要的三句话:

  1. 每次上传库存表后,请立刻按对账钮——拖几天再对,撿货单会被旧系统归档、对账会卡住(6/15 就发生过,只能手动重设基准)。
  2. 6/21 是周六,Amazon SF(FLEX)单出现「追平中」是正常的——它本来就常态慢 1~3 天,不是漏单。
  3. 系统的安全设计一律偏「宁可少卖、不要超卖」——所以你可能看到某些商品广播的数字比你预期保守,这是刻意的保护,不是错误。真正要盯的是对账报告里标红(🔴超48h未冲回 / 🔴超72h未对上)的项目。