深色
Amazon 三仓判定(FBM / FLEX / FBA)
I have all the evidence I need. The implementation is fully traced end-to-end, tests confirm behavior, and ADRs document the rationale. Let me compose the deep-dive analysis.
Amazon 三仓判定 FBM / FLEX / FBA — 核心原理深挖
来源已逐项对照程式码与 ADR,未对照之处明确标注。
一、问题本质:一个 WMS 货号,三种不同的「钱货关系」
Amazon 上同一个实体商品(同一个 WMS 6 位货号,例如 130127)可能挂三种不同的「履约模式」(fulfillment=由谁出货),而这三种对 CC WMS 的「虚拟库存帐本」(broadcast/pool=同一批货重复曝光给多平台、先卖先扣)有完全相反的处理要求:
| 模式 | 全称 | 货实际放在哪 | 谁出货 | 要不要从共享池扣? |
|---|---|---|---|---|
| FBM | Fulfilled by Merchant(自发货) | 自有仓(与 Takealot/Makro 同一批货) | 卖家自己 | 要扣(卖出会减少自有仓实体货) |
| FLEX | Seller Flex / SF(自营快递) | 自有仓(同上,共享同一批货) | 卖家自己仓出,Amazon 排快递 | 要扣(货从自有仓走,同 FBM) |
| FBA | Fulfilled by Amazon | Amazon 自己的仓(与自有仓物理隔离) | Amazon | 不扣(卖出动的是 Amazon 仓,不影响自有仓) |
这就是整套判定逻辑的存在理由:FBM 与 FLEX 必须扣共享池,FBA 绝不能扣。判错任一边都会直接造成 6/21 真实写入时的库存错误(详见第五节)。
二、判定的数学/状态机:两段式 + 一个例外覆写
实作在 cc-wms/src/services/connectors/amazon.ts:32-44,是一个两层决策:
第一层 — channel→variant(channelToVariant, 行 32-36) Amazon Orders API 每张订单回一个 FulfillmentChannel 栏位:
MFN(Merchant Fulfillment Network)→ FBM
AFN(Amazon Fulfillment Network) → FBA
其他/未知 → null第二层 — SF 尾缀覆写(resolveAmazonVariant, 行 41-44)
ts
export function resolveAmazonVariant(platformSku: string, channel: string): 'FBM' | 'FBA' | 'FLEX' | null {
if (platformSku.toUpperCase().endsWith('-SF')) return 'FLEX' // ← 尾缀优先,先短路
return channelToVariant(channel) // ← 否则才看 channel
}判定优先级(关键):先看 SKU 尾缀,尾缀赢过 channel。只有不是 -SF 结尾时,才退回用 channel 判 FBM/FBA。
用真值表表达(与测试 amazon.test.ts:81-95 完全一致):
| platform_sku | channel | 判定结果 | 说明 |
|---|---|---|---|
Miui-130127-SF | AFN | FLEX | 尾缀赢,无视 channel 报的 AFN |
Miui-130127-sf | AFN | FLEX | 大小写不敏感(先 toUpperCase()) |
Miui-999-SF | MFN | FLEX | 尾缀赢 |
Miui-136550 | MFN | FBM | 退回 channel |
Miui-141083-FBA | AFN | FBA | 退回 channel(注意 -FBA 不是 -SF) |
Miui-136550 | XXX | null | 未知 channel |
SF-Miui-1 | MFN | FBM | 不误判:SF- 是开头不是尾缀 |
Miui-1-SFX | AFN | FBA | 不误判:-SFX 不以 -SF 结尾 |
三、为什么需要「尾缀覆写」这一层?—— 它在防的第一个错误
这是整个设计最反直觉、也最容易被未来的人改坏的地方,程式注解写得很清楚(amazon.ts:38-40):
Seller Flex 判定:SP-API 不提供 SF 标记(SF 单回报 AFN,与 FBA 无法从 FulfillmentChannel 区分),唯一可靠记号是本店 SKU 命名约定(
Miui-xxx-SF)。SF 货与 Takealot/Makro/FBM 共享同一实体池(Nate 2026-06-10 确认)→ 必须扣池,故归 FLEX 而非 FBA。
拆开讲:
Amazon API 帮不了你:Seller Flex 的订单在
FulfillmentChannel栏位回报的是AFN——和真正的 FBA 一模一样。光看 API 栏位,FLEX 和 FBA 无法区分。但两者对库存的意义相反:FLEX 的货在自有仓(和 FBM 同一批货),卖出去要扣共享池;FBA 的货在 Amazon 仓,卖出去不能扣。
唯一可靠的区分信号是卖家自己的 SKU 命名约定:自有仓走 Seller Flex 的 listing,SKU 一律以
-SF结尾。所以代码用「SKU 尾缀」这个卖家可控的人造信号,去补 Amazon API 缺失的区分能力。
如果没有这层覆写会怎样:所有 -SF 单会因为 channel=AFN 被判成 FBA → 不扣池 → 自有仓的货被 Amazon FLEX 卖掉了但 CC WMS 帐面没减 → 广播给其他平台(Takealot/Makro)的库存虚高 → 超卖。
防误判的两个边界(测试 amazon.test.ts:92-94 锁死):用的是 endsWith('-SF') 而非 includes('SF'),所以 SF-Miui-1(开头)和 Miui-1-SFX(中间)都不会被误抓成 FLEX。
四、「variant=FBA 双义陷阱」—— 同一个 'FBA' 字串代表两种相反的货
这是 MEMORY 里点名的第二个陷阱,也是整套逻辑里最隐蔽的地方。variant='FBA' 这个值在 orders 表里有两种完全相反的含义,靠 platform_order_id 的前缀区分:
| 哪种 'FBA' | 怎么产生 | platform_order_id 长相 | 货在哪 | 扣不扣池 |
|---|---|---|---|---|
| 真 FBA 销售单 | connector 从 Amazon 拉回的真实订单(amazon.ts:312 判出 variant=FBA) | 真实订单号,如 403-1916111-1275551-Miui-xxx | Amazon 仓 | 不扣(永不被 PDA 扫码结算,计入会永久压低广播) |
| FBA 虚拟扣减单 | 操作员上传「FBA/DC 扣减 CSV」时人造的(wms.ts:128-131,写死 variant:'FBA' + 前缀 fba_deduct_) | fba_deduct_{importId}_{i} | 货正离开自有仓送去 Amazon FBA 仓 | 要扣(货真的从自有仓走了) |
为什么要造一个假的 FBA 单去扣池? 当操作员把自有仓的货打包送去 Amazon FBA 仓补货时,这批货确实离开了自有仓,必须从共享池扣掉(否则还会广播给 Takealot 卖,但货已经不在了 = 超卖)。但这不是一张真订单,所以系统用「虚拟扣减单」(代位扣减) 机制——造一笔 fba_deduct_ 开头、variant='FBA' 的假订单去占用 pending,效果等同扣池。(ADR-038/045)
两个含义如何在 SQL 里被正确分开——这是防超卖的咽喉句。pending 汇总的 WHERE 条件(db/ledger.ts:54 与全量重算 services/ledger.ts:109 必须字字一致):
sql
AND NOT (variant IS 'FBA' AND platform_order_id NOT LIKE 'fba_deduct_%')白话翻译这行:「排除掉『是 FBA 且不是 fba_deduct_ 开头』的单」——也就是只排除「真 FBA 销售单」,而 fba_deduct_ 虚拟单因为带前缀,被这个双重条件放行、照常扣池。
这行里藏着两个必须严谨理解的正确性细节:
(1) 必须用 IS 不能用 =(NULL-safe 陷阱,db/ledger.ts:46-48 注解) Takealot/Makro/Temu 的订单 variant 一律是 NULL。SQLite 里 NULL = 'FBA' 求值为 NULL(不是 false),NOT (NULL AND ...) = NULL → 该列被 WHERE 整列排除 → 三大平台的 pending 全部不扣 = 广播全面虚高 = 超卖。改用 SQLite 的 IS(NULL IS 'FBA' 回 false),列才会保留。这是一个「写错一个运算符就全平台超卖」的地雷,注解专门警告。
(2) 这个双义逻辑被复制了 5 处,必须同步getPendingOrdersQty(db/ledger.ts:54)、全量重算 SQL(services/ledger.ts:109)、明细展示(routes/ledger.ts:161)、订单页筛选(routes/orders.ts:24)、漏接哨兵(missed-order-sentinel.ts:327)。任一处口径不一致,帐本与显示/对账就会对不上。
五、与 6/21 真实写入相关的正确性风险
6/21 第一次真实写入会做两件事:推送虚拟库存到平台(影响 FBM/FLEX)+ 线上比价改价(与本主题无关)。逐项评估库存判定的风险:
风险 1(中-高):FLEX 推送路径与判定脱节,可能把自有仓库存推错地方 —— 待 Nate 确认营运事实
判定逻辑(拉单扣池)做得很干净,但推送端是另一回事。pushStock(amazon.ts:395-473)走 Listings Items API,写的是 fulfillment_availability 里 fulfillment_channel_code: 'DEFAULT' 的数量(行 430-431)。
- FBM 推送:合理,DEFAULT channel 就是自发货库存。
- FLEX 推送:存疑。FLEX 的 listing 是
-SF结尾的独立 SKU,它在 Amazon 端是否用同一个DEFAULTfulfillment channel、推 broadcast_qty 上去是否正确,代码层面没有专门针对 FLEX 的推送分支处理。CC WMS 把-SF当成一个普通 SKU 推送它算出的 broadcast 值。这块代码里看不出有验证过 FLEX listing 的真实推送行为——属于「拉单逻辑严谨、推送逻辑未针对 FLEX 特化」的不对称。6/21 推 FLEX SKU 前建议 Nate 确认:FLEX listing 的库存是否就是用一般 Listings API 的 DEFAULT channel 管理。
风险 2(中,已知、代码自己标注):productType 写死 'PRODUCT',首次真实推送可能被拒
pushStock 的 body 里 productType: 'PRODUCT'(amazon.ts:425),代码自己的 TODO 注解(行 420-423)明说:这是 offer-only listing 的通用 fallback,若某 SKU 真实 productType 是完整支援类型,Amazon 可能拒绝此值,且「为写入路径,只读沙盒不会执行,首次真实推送前须以真实 listing 验证」。6/21 正是「首次真实推送」,这是代码留给自己的明确待办。这不是判定 bug,但会让推送在 6/21 当场失败(被拒)——属于必须当天盯的项目。
风险 3(低,但要知道):FBA 双义靠「字串前缀」维系,无 schema 强制
真 FBA 与虚拟扣减单的区分完全靠 platform_order_id 是否以 fba_deduct_ 开头,没有独立栏位、没有 DB 约束保证。只要哪天有人改了虚拟单的命名前缀、或新增一种虚拟单忘了走这个前缀约定,5 处 SQL 的 NOT LIKE 'fba_deduct_%' 会立刻全部失准。目前 576+ 测试覆盖了行为,但这是「约定优于强制」的结构性脆弱点。6/21 本身不直接触发,但属于上线后要留意的技术债。
风险 4(低):-SF 判定依赖卖家命名纪律
整个 FLEX 判定的根基是「自有仓 Seller Flex 的 SKU 一定以 -SF 结尾」这条人为约定。若 Amazon 后台有任何一个走 Seller Flex 的 listing 漏加 -SF 尾缀,它会被判成 FBA(channel=AFN)→ 不扣池 → 超卖。这不是代码能防的,是营运纪律风险,但直接关系到 6/21 之后 FLEX 库存的正确性。
不构成风险的部分(可放心)
- 拉单扣池判定本身严谨:两段式判定 + 尾缀覆写 + NULL-safe SQL + 双义前缀区分,逻辑自洽,测试齐全(
amazon.test.tsP01-P04 + resolveAmazonVariant 三组 + ledger 测试)。 - FBA 真销售单不扣池已正确隔离,不会因为 Amazon 卖 FBA 货而错压自有仓广播。
- Pending 即时占池(ADR-049)已修,FLEX 单从下单到隔日的「隐形超卖窗」已堵上——Pending 阶段只收
-SF品项即时占池(amazon.ts:300-314),纯 FBM 等付款确认,FBA 永不提前入。这条对 6/21 是利好(FLEX 不会再有 30 小时不扣池的窗口)。
六、给非技术读者的白话总结
一句话:同一件商品在 Amazon 上有三种「由谁发货」的卖法,CC WMS 必须正确判断每张订单属于哪种,才不会把已经卖掉/已经送走的货又拿去别的平台卖(超卖)。
三种卖法:
- FBM(自己发货) 和 FLEX(自己仓出货、Amazon 帮叫快递):货都在你自己的仓库,和 Takealot、Makro 共用同一批货。卖掉一件,就要从「共享库存」里减一件。
- FBA(亚马逊代发):货放在亚马逊自己的仓库,和你的仓库是分开的。卖掉不影响你的仓库,所以不能去减共享库存。
两个最关键的「坑」,系统都已经填好了:
FLEX 伪装成 FBA 的坑:亚马逊的系统回报订单时,把 FLEX 和 FBA 标成一样(都叫 AFN),分不出来。系统的解法是看商品编号的结尾——凡是
-SF结尾的,就是 FLEX(要减库存),其余 AFN 才是真 FBA(不减)。这是靠你们自己「FLEX 商品编号都加 -SF」的命名习惯撑起来的,所以上架 FLEX 商品时编号一定要记得加 -SF,否则系统会当成亚马逊代发、不减库存、最后超卖。「FBA」这个标签有两个相反意思的坑:有时候你们会把货从自己仓库送去亚马逊仓库补货,这批货确实离开了自己仓库、要减库存。系统用一种叫「虚拟扣减单」的假订单来记这件事,它也标成 FBA,但订单号开头是
fba_deduct_。系统靠这个开头,把「真正在亚马逊卖的 FBA 单(不减)」和「送去亚马逊补货的扣减单(要减)」精准分开。
6/21 上线测试要特别盯的两件事(都不是判断逻辑错,而是「第一次真的写出去」才会遇到的):
- 推送给 FLEX 商品的库存数字是否真的写对地方——拉单算库存这边做得很扎实,但「把数字推回亚马逊」这一步,代码里没有专门针对 FLEX 特别处理,建议先拿一个 FLEX 商品小量试推、确认亚马逊后台数字对了再放量。
- 推送可能被亚马逊当场拒绝——代码里有一个商品类型参数是先写死的通用值,开发者自己注解说「第一次真推可能被拒、要拿真实商品验过」。6/21 正是第一次真推,要做好「某些商品推不上去、当场要查」的准备。
关键文件路径(均为绝对路径):
- 判定逻辑:
C:\Users\langl\Downloads\CC WMS\cc-wms\src\services\connectors\amazon.ts(resolveAmazonVariant行 41-44、channelToVariant行 32-36、Pending 占池 行 296-314、pushStock行 395-473) - 扣池数学(双义守卫):
C:\Users\langl\Downloads\CC WMS\cc-wms\src\db\ledger.ts(行 41-59)与C:\Users\langl\Downloads\CC WMS\cc-wms\src\services\ledger.ts(全量重算 行 105-111;广播公式 行 40-64) - FBA 虚拟扣减单产生处:
C:\Users\langl\Downloads\CC WMS\cc-wms\src\routes\wms.ts(行 124-145) - variant 型别与三仓语义注解:
C:\Users\langl\Downloads\CC WMS\cc-wms\src\services\connectors\types.ts(行 26-31) - 测试佐证:
C:\Users\langl\Downloads\CC WMS\cc-wms\src\services\connectors\amazon.test.ts(行 78-96 判定、行 251-304 Pending) - ADR:
C:\Users\langl\Downloads\CC WMS\docs\13-決策記錄.md(ADR-009 FBA 只读不写 行 15、ADR-010 variant 区分 行 16、ADR-045 fba_deduct 退场 行 51、ADR-048 FLEX 卡单巡检 行 53、ADR-049 SF Pending 即时占池 行 55)