深色
每日对账「两个时钟」+ 预留生命周期
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)程式实作在 computeRecon(bridge-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_imports | wms_imports 表,runCloudRecon 取 t0/t1 |
| 撿货时钟(pick clock) | pickOut,撿货单已撿量(yetNum)的「增量」 | 只在每次跑对账时,把当前撿货量减去「上次对账时记下的撿货量」 | KEY_RECON_PICK_STATE(存在 system_config),computePickWindow bridge-recon.ts:154 |
关键差异(bridge-recon.ts:80-83 注释明写):
- 撿货出库是「消耗性的 Δ」:
computePickWindow用Math.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 修法:把两个时钟锚在同一个起点
修复函数 reconAnchorT0Id(bridge-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_LAST,bridge-recon.ts:606-614): 撿货 Δ 是消耗性的——同一对快照重复跑,第二次时 pickOut 会被算成 0(因为基准已被第一次推进到当前值),守恒式立刻崩。所以同一对 pairKey(${t0.id}-${t1.id})第二次点,直接回上次报告,不重算。
② 假报告自我怀疑 looksLikeStaleRerun(bridge-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 = 48h,bridge-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:false与po_state:'shipped'并存,通篇无 draft 概念)。 - 而且 confirm(撿货完成离仓)→ 下次上传之间,货已经在卡车上无法再让位给订单 → 这段必须有预留挡着,否则是真超卖窗。
- 结论:
dcReserved含 draft(shipment-reservations.ts:124的getDcReservedQty和ledger.ts:115的全量 SQL 两处条件必须一致:item_type='replenishment' AND cancelled=0 AND status='active',不过滤 po_state)。
(B) 退场判别 = shipped 旗标,绝不用 po_state(settle.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 位置 |
|---|---|---|---|
| A | DC 补货明细(replenishment) | shipped=1 → status='settled' | settle.ts:26-32 |
| C | Amazon/Makro leadtime 订单 | status='shipped' 且 platform <> 'takealot' → 写 settled_at | settle.ts:38-43 |
| D | fba_deduct_* 虚拟扣减单 | platform_order_id LIKE 'fba_deduct_%' → 写 settled_at(ADR-045) | settle.ts:50-55 |
| B | Takealot leadtime(FIFO 额度消费) | confirmed customer_order 明细 shipped=1,按 wms_code 先进先出退最旧订单 | settle.ts:91(settleLeadtimeFifo) |
| E | TTL 保险阀 | 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 栏):
- 算每个 wms_code「已确认且尚未消费」的待退量:
SUM(MAX(0, quantity - consumed_qty)),只看shipped=1(settle.ts:95-99) - 撈该 code 最旧的未结算 Takealot leadtime 订单(
ORDER BY ordered_at ASC, id ASC) - FIFO 整单累加,累计「不超过」ship_qty 才退(
if (acc + o.qty > ship_qty) break,settle.ts:125)——保守,宁可少退不超卖 - 把退掉的量按最旧明细行优先摊销
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)。
computeRecon(bridge-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=0stale →looksLikeStaleRerun命中 → 报告自承无效、卡死循环。 - 这正是 6/15 发生的:上次真对账是 6/12(pair
3-4),拖到 6/15 才对,撿货单已归档无法重建 → 只能手动 rebaseline(docs/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
computeOrderMatch(bridge-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 上线测试最重要的三句话:
- 每次上传库存表后,请立刻按对账钮——拖几天再对,撿货单会被旧系统归档、对账会卡住(6/15 就发生过,只能手动重设基准)。
- 6/21 是周六,Amazon SF(FLEX)单出现「追平中」是正常的——它本来就常态慢 1~3 天,不是漏单。
- 系统的安全设计一律偏「宁可少卖、不要超卖」——所以你可能看到某些商品广播的数字比你预期保守,这是刻意的保护,不是错误。真正要盯的是对账报告里标红(🔴超48h未冲回 / 🔴超72h未对上)的项目。