系统接入:本阶段的判断
系统接入路径第 3 步:对齐数据与流程。本阶段先确认适用范围、证据和负责人,再继续下一步。
将各实体放入Shipping API 技术总览的执行步骤,再按订单、运单与轨迹状态指南把状态变化对应到正确实体,避免系统间共用一个状态字段。
OMS、WMS、3PL 与承运商之间每一次集成,最终都会撞上同一个词汇问题:一个系统叫它「订单」,另一个叫它「发货」,第三个坚持叫「包裹」,而承运商要的是一张带追踪号的「面单」。这些词看起来可以互换,而一旦被当成同义词对待,bug 就跟着来了:重复发货、追踪号丢失、对账永远对不平。

系统接入路径第 3 步:对齐数据与流程。本阶段先确认适用范围、证据和负责人,再继续下一步。
将各实体放入Shipping API 技术总览的执行步骤,再按订单、运单与轨迹状态指南把状态变化对应到正确实体,避免系统间共用一个状态字段。
先看一个具体的示例订单,让抽象概念有个安身之处。在这个示例里,客户在一次结账中买了三件商品:两个陶瓷马克杯和一件棉 T 恤,订单总额 170.11 美元。库存分散在两个仓库,OMS 决定马克杯从东岸仓库发货,T 恤从西岸仓库发货。
注意刚刚发生了什么。一个订单产生了两个履约(每个仓库一个)、两个包裹(每个箱子一个)、三个追踪号,因为 P1 走了两段、用了两家承运商。追踪号挂在包裹上,不挂在订单上。如果你在订单行上开一列「tracking_number」,这三个号里你打算填哪个?
这个问题就是本文的论点:1 个订单 → N 个履约 → N 个包裹 → N 张面单,追踪归属包裹。后面所有内容,都是为了让这条层级结构可以安全地承载业务:防止重复发货、永不丢失追踪号、每一分钱都能追溯回订单和客户。
四个实体,四个不同的职责。把任何两个搞混,集成就会开始腐烂。
与厂商无关的关系树:
Order
└── Fulfillment / Shipment
├── Package
│ ├── Label
│ └── Tracking Number
└── Charges / Adjustments / Reversals

这棵树里有两条关系最值得强调,因为它们是大家最爱压平掉的东西。第一,一个履约可以产出多个包裹:三箱的订单是一个履约,不是三个。第二,一个包裹可以带多个追踪号:DHL 飞到边境、再转 USPS 送到门口的包裹有两段,每段都有自己的承运商和追踪号。如果你直接建模「order → tracking」,这两个事实都会消失。
为什么第二层叫「Fulfillment」而不是「Shipment」?因为平台们口径不一:Shopify 叫 fulfillment,Amazon 把 FBA 批次叫 shipment,多数 WMS 把每次派送都叫 shipment。树必须保持与厂商无关,所以这一层就是 fulfillment/shipment 这个概念本身,不管某个平台怎么叫。你的适配器把平台的名字映射到这个节点就停了,树的其他部分永远不变。想深入理解为什么订单状态、发货状态和追踪状态总是对不上,可以读姊妹篇订单状态 vs 发货状态 vs 追踪状态。
每个实体都需要一套小而稳定的字段。下面是我们建议的最小集,每个实体一张表。
| 实体 | 字段 | 用途 |
|---|---|---|
| order | id | 规范 UUID |
| order | external_reference | 平台订单号(SHOP-1001),用于关联 |
| order | customer_id | 商业承诺的归属客户 |
| order | line_items | 承诺了哪些商品 |
| order | totals | 以订单币种计收了多少钱 |
| order | status | 承诺的生命周期 |
| fulfillment | id | 规范 UUID |
| fulfillment | order_id | 回指承诺 |
| fulfillment | warehouse_id | 由谁履约 |
| fulfillment | status | 动作的生命周期 |
| fulfillment | idempotency_key | 防止重复履约 |
| fulfillment | created_at | 动作开始时间 |
| package | id | 规范 UUID |
| package | fulfillment_id | 由哪个动作产出 |
| package | weight, dimensions | 承运商按什么计费 |
| package | tracking | 分段数组,每段一个 |
| package | status | 箱子的生命周期 |
| label | id | 规范 UUID |
| label | package_id | 授权的是哪个箱子 |
| label | carrier | 哪家承运商签发 |
| label | label_format, label_url | PDF 存放在哪 |
| label | status | purchased / reprinted / voided |
| label | cost | 金额 + 币种,供计费用 |
| charge | id | 规范 UUID |
| charge | fulfillment_id | 由哪个动作产生 |
| charge | charge_type | shipping / adjustment / reversal |
| charge | amount, currency | 带符号的资金变动 |
| charge | reason | 为什么,供对账用 |
| charge | applied_to | 可选,回指面单或包裹 |
这张表里有两个字符串字段常年被搞混。idempotency_key 防止同一意图被重复提交:重试请求、复用 key、拿回同一个实体。external_reference 用来关联外部系统的标识符。一个是重试安全,一个是关联键。两者都值得拥有真正的字段,谁也不该被挪作他用。
状态机,刻意保持很小:
Order: placed → partially_fulfilled → fulfilled → closed
placed → canceled
Fulfillment: pending → processing → shipped → delivered
pending → canceled
shipped → exception
Package: created → labeled → in_transit → delivered
in_transit → exception
Label: purchased → reprinted* → voided
*reprint does not change identity; void is terminal
三条规则让这些状态机保持诚实。订单状态由其履约推导:部分履约已发货时为 partially_fulfilled,只有所有未取消的履约都发货了才是 fulfilled。包裹状态是追踪页展示的真相来源,别让履约状态和它的包裹漂移开。面单的 reprinted 不是新实体,只是「PDF 被再次拉取」的备注,而 voided 是终态,并且总是伴随一笔冲销(详见下面的设计决策)。
先定约定,因为每个系统各写各的时候,示例根本没法读。字段名用 snake_case。所有规范 ID 用 UUID。所有时间戳用 ISO 8601 UTC。一切嵌套都和关系树完全一致,这让数据自洽:追踪活在包裹下,费用活在履约下,每个履约都带着 order_id 回指商业承诺。
下面是完整订单的嵌套视图,为可读性做了精简:
{
"order": {
"id": "9f2c1d4e-6a3b-4c8d-9e0f-1a2b3c4d5e6f",
"external_reference": "SHOP-1001",
"status": "fulfilled",
"fulfillments": [
{
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"warehouse_id": "wh_east",
"status": "shipped",
"packages": [
{
"id": "3c4d5e6f-7a8b-4c9d-0e1f-2a3b4c5d6e7f",
"weight_kg": 2.4,
"tracking": [
{ "carrier": "DHL", "carrier_tracking_id": "DHL1234567890", "status": "delivered_to_last_mile" },
{ "carrier": "USPS", "carrier_tracking_id": "9400111899223198765432", "status": "in_transit" }
],
"labels": [
{ "id": "5e6f7a8b-9c0d-4e1f-2a3b-4c5d6e7f8091", "carrier": "DHL", "status": "purchased", "cost": { "amount": 18.40, "currency": "USD" } }
]
}
],
"charges": [
{ "id": "7a8b9c0d-1e2f-4a3b-4c5d-6e7f8091a2b3", "charge_type": "shipping", "amount": 18.40 },
{ "id": "8b9c0d1e-2f3a-4b4c-5d6e-7f8091a2b3c4", "charge_type": "adjustment", "amount": 6.20, "reason": "declared_value_insurance" }
]
},
{
"id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
"warehouse_id": "wh_west",
"status": "shipped",
"packages": [
{
"id": "4d5e6f7a-8b9c-4d0e-1f2a-3b4c5d6e7f80",
"weight_kg": 0.9,
"tracking": [
{ "carrier": "USPS", "carrier_tracking_id": "9400111899223198765433", "status": "delivered" }
],
"labels": [
{ "id": "6f7a8b9c-0d1e-4f2a-3b4c-5d6e7f8091a2", "carrier": "USPS", "status": "voided", "cost": { "amount": 12.85, "currency": "USD" } },
{ "id": "0e1f2a3b-4c5d-4e6f-8a90-1b2c3d4e5f60", "carrier": "USPS", "status": "purchased", "cost": { "amount": 13.40, "currency": "USD" } }
]
}
],
"charges": [
{ "id": "8f9a0b1c-2d3e-4f4a-5b6c-7d8e9f0a1b2c", "charge_type": "shipping", "amount": 12.85 },
{ "id": "9c0d1e2f-3a4b-4c5d-6e7f-8091a2b3c4d5", "charge_type": "reversal", "amount": -12.85, "reason": "voided_label" },
{ "id": "0d1e2f3a-4b5c-4d6e-7f80-91a2b3c4d5e6f", "charge_type": "shipping", "amount": 13.40 }
]
}
]
}
}
一个包裹带多段追踪,这是让多承运商运输保持诚实的形态:
{
"id": "3c4d5e6f-7a8b-4c9d-0e1f-2a3b4c5d6e7f",
"fulfillment_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"status": "in_transit",
"weight_kg": 2.4,
"dimensions_cm": { "length": 30, "width": 20, "height": 12 },
"tracking": [
{ "segment": 1, "carrier": "DHL", "carrier_tracking_id": "DHL1234567890", "status": "delivered_to_last_mile" },
{ "segment": 2, "carrier": "USPS", "carrier_tracking_id": "9400111899223198765432", "status": "in_transit" }
],
"created_at": "2026-08-21T13:52:18Z"
}
一张面单带着它的成本,因为计费需要用到:
{
"id": "5e6f7a8b-9c0d-4e1f-2a3b-4c5d6e7f8091",
"package_id": "3c4d5e6f-7a8b-4c9d-0e1f-2a3b4c5d6e7f",
"carrier": "DHL",
"label_format": "PDF",
"label_url": "https://labels.example.com/5e6f7a8b-9c0d-4e1f-2a3b-4c5d6e7f8091.pdf",
"status": "purchased",
"cost": { "amount": 18.40, "currency": "USD" },
"purchased_at": "2026-08-21T14:05:37Z"
}
然后是履约底下的资金轨迹:一笔运费、一笔调整、之后一笔冲销。调整修正之前的金额(正或负)。冲销精确镜像一笔费用,以负数记录,绝不修改原始行:
{
"charges": [
{
"id": "7a8b9c0d-1e2f-4a3b-4c5d-6e7f8091a2b3",
"fulfillment_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"charge_type": "shipping",
"amount": 18.40,
"currency": "USD",
"description": "DHL express label for package 3c4d5e6f",
"created_at": "2026-08-21T14:05:37Z"
},
{
"id": "8b9c0d1e-2f3a-4b4c-5d6e-7f8091a2b3c4",
"fulfillment_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"charge_type": "adjustment",
"amount": 6.20,
"currency": "USD",
"reason": "declared_value_insurance",
"description": "Additional declared-value insurance added after purchase",
"created_at": "2026-08-21T15:02:11Z"
}
]
}
字段名和状态机是容易的百分之二十。另外百分之八十是横跨实体的决策:重试在哪里变安全、面单怎么死、包裹怎么跨承运商追踪、钱怎么上卷、产品每一层各自保留哪些 ID。这五个做对了,模型就能扛住真实流量:拆单、多段承运、作废风暴和月度对账。
幂等恰好需要三个层,每层各用自己的 key:
模式处处相同。调用方生成一个 UUID 作为 idempotency_key 发出。服务端按 (key, entity_type) 对去重:同一个 key 可以创建一个订单和一张面单,因为实体类型不同。key 有界的过期窗口,通常 24 到 48 小时[^5],之后新的意图需要新 key。任何重试都复用同一个 key,服务端返回之前创建的实体,而不是再建一个。
把 key 放错层,钱就从这里漏。订单 POST 不带 key 重试,得到双倍订单。面单 POST 不带 key 重试,承运商为一个箱子收你两次钱。履约不带 key 重试,WMS 会把同样的货物理上发出两遍。履约 key 是团队最常忘的那个,因为履约是在更大的工作流内部创建的,看起来像内部步骤。它不是:它是移动真实箱子的那一步。关于重试策略和旺季压测,可以看Shipping API 压测指南。
一张面单恰好有三种状态,其中两种很无聊。purchased 是幸福状态。reprinted 是同一张面单、同一个 id、同一笔费用,只是打印机卡纸后重新拉取了一次 PDF;它在数据模型里只改一个状态标记和一行审计记录。voided 才有意思,因为它是终态,而且要动钱。
作废一张面单意味着三件事同时发生,理想情况下在一个事务性 API 调用里完成:
voided,终态。reversal 冲销。唯一会创建全新面单 id 的路径是作废-替换:先作废旧面单、确认冲销、再买新面单。顺序很重要。绝不让两张不同号码的面单在同一时间对同一包裹有效,因为仓库扫描枪会高高兴兴地把最新的面单贴到箱子上,而承运商会拒收这一对。先作废,再购买。
POST /v1/labels/6f7a8b9c-0d1e-4f2a-3b4c-5d6e7f8091a2/void
{
"reason": "address_correction",
"idempotency_key": "d1e2f3a4-5b6c-4d7e-8f90-1a2b3c4d5e6f"
}
200 OK
{
"label": {
"id": "6f7a8b9c-0d1e-4f2a-3b4c-5d6e7f8091a2",
"status": "voided",
"voided_at": "2026-08-21T16:44:02Z"
},
"reversal": {
"id": "9c0d1e2f-3a4b-4c5d-6e7f-8091a2b3c4d5",
"fulfillment_id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
"charge_type": "reversal",
"amount": -12.85,
"currency": "USD",
"reason": "voided_label",
"label_id": "6f7a8b9c-0d1e-4f2a-3b4c-5d6e7f8091a2"
}
}
注意作废调用自己也带幂等 key。作废同样是副作用操作:重试的作废请求必须返回同样的结果,而不是抛一个客户端无法和真实失败区分的「面单已作废」错误。
追踪挂在包裹上,不挂在履约上,更不挂在订单上。一个包裹可以走好几段,每一段都是自己的 (carrier, tracking number) 对,各有各的事件流。示例里的国际包裹有两段:DHL 走出口段,USPS 走最后一公里。如果追踪是订单上的一列,这个包裹早就把你的 schema 撑爆了。跨境多承运商的轨迹怎么保持身份连续,可以看跨境多承运商追踪指南。
package.tracking[segment].events。履约状态从其全部包裹的最差状态推导,订单状态从履约推导。上卷自下而上,绝不让承运商 webhook 直接写订单状态。复合键比看起来更重要。追踪号不是全局唯一的。「9400111899...」单独没有任何含义;「USPS 9400111899223198765432」才精确指代一个包裹。任何只按追踪号做的关联都是一颗等着碰撞的 bug。
费用活在履约下,这给了你一条确定的上卷路径:任何一美元都能追溯 fulfillment_id → order_id → customer_id。东岸履约的 18.40 美元面单费和 6.20 美元保价调整上卷到订单 SHOP-1001 和客户 cus_38d2。西岸履约的 12.85 美元运费、-12.85 美元冲销和 13.40 美元替换费走同一条路。每美元都有自己的家,没有哪一美元有两个家。对账的完整方法论见物流对账指南。
账本规则简单而绝对:绝不修改已有费用。面单作废就写一笔冲销行,不删不改原始费用。承运商附加费一周后才到,就写一笔调整行。这是对账之所以可能的根基:账本只追加,每行都带 reason。
对账于是收敛为一条不变式:
order shipping cost = sum(charges) + sum(adjustments) + sum(reversals) across its fulfillments
对我们的订单:东岸 (18.40 + 6.20) 加西岸 (12.85 - 12.85 + 13.40) 等于 38.00,这个数字必须和承运商对两个包裹开出的账单一致。不一致时,差异会指向一个具体的履约和一笔具体的费用行,这正是把钱挂在履约而不是订单或客户上的全部意义。客户级计费是这些行的上卷,不是另一本账。
产品每一层都应该存自己实际用到的 ID,两层在一条写明的关联键上相遇。把两者搞混,客服工单就会以「你给我的追踪号和包裹上的不一样」开场。
| 关注点 | 门户(面向人) | API / 系统记录 |
|---|---|---|
| 订单身份 | 平台订单号,如 SHOP-1001 | 规范订单 UUID |
| 履约 | 履约展示码 | 履约 UUID |
| 包裹 | 「第 1 件 / 共 2 件」 | 包裹 UUID |
| 追踪 | 展示用追踪号 | 规范追踪值 + 承运商原始 id |
| 面单 | 票据上印的面单号 | 面单 UUID + 承运商面单 id |
| 计费 | 账单行项目 | 费用 / 调整 / 冲销 UUID |
门户存人能读、能引用、能粘贴的东西:平台订单号、承运商印出来的追踪号、票据上的面单号。API 和系统记录存规范 UUID 加承运商原始 id,因为它们在重打、平台迁移和承运商改名面前保持稳定。门户与 API 的先后顺序怎么选,见门户优先还是 API 优先。
关联键约定是两层之间的契约:canonical_id ↔ external_reference ↔ portal display id,放在一张映射表里,绝不推断。别让门户的展示 id 当主键,也别让 UUID 成为人找订单的唯一途径。有人打电话说「我的订单 1001」,映射表一次查询就把它变成规范 id。
本页的关系树、状态机与 JSON 是逻辑参考,不是任何一个供应商接口的原样响应。对 OMS、ERP、WMS、门户或 API,分别写清实际字段、保存位置、负责系统及核对方法,再把它们映射回订单、发货、包裹和面单。不能只因为字段名字相同就认为金额状态或实体含义相同。
| 映射对象 | 需要保留什么 | 验收证据 |
|---|---|---|
| 客户、仓库与订单 | 来源系统引用、权限范围、起运仓与订单承诺。 | 同一测试订单能在来源与执行端对应,不串客户或仓库。 |
| 发货与包裹 | 拆单关系、包裹身份、实测重量尺寸、服务及交接状态。 | 拆成多个包裹后仍能回到同一订单,分别说明每件结果。 |
| 面单与追踪 | 原面单、作废/替换关系、承运商与运单号、事件来源。 | 旧事件不覆盖新面单状态,重打与重新购买有明确区别。 |
| 报价与费用 | 报价、购买、调整、信用与客户收费的身份和截止点。 | 金额能回到包裹、面单、订单及客户,待收退款单独保留。 |
以本页拆单示例为测试夹具,用明确标注的测试数据走完选择服务、创建面单、查询、作废或替换及费用核对。作废请求成功不自动代表退款已到账;供应商没有退款资格或状态证据时,保持未决并安排负责人。对应费用方法见 物流对账。
确认这些关系后,进入 WMS 与发货接口的失败场景验收。把每个失败场景的输入、观察到的结果、恢复方式和责任人绑定到这份映射表,而不是只看接口是否返回成功。
下面是整段旅程的时序图,从客户到 OMS 到 WMS 到承运商,重试、作废和多承运商节点都标在它们真实发生的位置:

这段时间线里有三个时刻是前面决策兑现回报的地方。alt 分支是幂等 key 在干活:购买请求超时,重试复用同一个 key,承运商返回已存在的面单而不是扣两次钱。作废分支是冲销规则在起作用:面单死掉、钱被镜像、替换购买是一个干净的新面单 id。最后的妥投事件带着包裹第二段的追踪号,这之所以成立,只因为模型把追踪存到了包裹上,而不是订单上。
与厂商无关的模型是你的中间层。每个平台配一个适配器,把它的概念映射到树上,方向永远一致:平台 → 中立模型。绝不做平台 → 平台。接入第五个平台时,你写一个适配器,而不是四条新的两两集成。
| 我们的实体 | 规范含义 | 规范字段 | 关联键 |
|---|---|---|---|
| order | 商业承诺 | id、external_reference、customer_id、line_items、totals、status | id ↔ external_reference |
| fulfillment | 履约动作 | id、order_id、warehouse_id、idempotency_key、status | id ↔ 平台履约 id |
| package | 物理容器 | id、fulfillment_id、weight、dimensions、tracking[]、status | id ↔ 平台包裹 id |
| label | 承运商票据 | id、package_id、carrier、status、cost | id ↔ 承运商面单 id |
| charge | 资金变动 | id、fulfillment_id、charge_type、amount、currency | id ↔ 平台费用 id |
| 我们的实体 | Shopify 概念 | 字段映射 | 备注 |
|---|---|---|---|
| order | Order | order.id、order.name | name 是人读的订单号,如 #1001 |
| fulfillment | Fulfillment / FulfillmentOrder | fulfillment.id、fulfillment_order.location_id | 追踪字段活在 Fulfillment 上[^1] |
| package | 无 | 由 line_items + fulfillment 合成 | 适配器把商品分组进包裹 |
| label | 核心 API 无 | 经 Shopify Shipping 或承运商应用 | label_url 存履约 metafields |
| tracking | Fulfillment 的 tracking_number、tracking_company、tracking_urls + FulfillmentEvent | 映射到 package.tracking[] | 一个履约可带多个追踪号 |
| 我们的实体 | SP-API 概念 | 字段映射 | 备注 |
|---|---|---|---|
| order | Orders API: Order | AmazonOrderId | |
| fulfillment | FBA: Fulfillment Shipment;MFN: Merchant Fulfillment Shipment | shipmentId | 两种履约模式是两套不同的 API[^3] |
| package | FBA: FulfillmentShipmentPackage;MFN: Shipment 上的包裹级追踪 | packageId | |
| label | MFN: Merchant Fulfillment ShippingLabel;FBA: shipment label | labelDownloadURL | 购买面单是独立 API 调用(buyShipping)[^2] |
| tracking | FBA: package tracking;MFN: trackingId | trackingNumber、trackingId |
| 我们的实体 | WooCommerce 概念 | 字段映射 | 备注 |
|---|---|---|---|
| order | Order (WC_Order) | order.id、order.number | |
| fulfillment | 无 | 订单状态流转:processing → completed | 适配器把状态变更当履约 |
| package | 无 | 合成 | |
| label | 无 | 插件或外部面单服务 | label_url 存 order meta |
| tracking | Shipment Tracking 插件:order meta 上的 tracking_number、tracking_provider、tracking_link | 映射到 package.tracking[] | 插件按订单挂 meta,不按包裹[^6] |
| 我们的实体 | Etsy 概念 | 字段映射 | 备注 |
|---|---|---|---|
| order | Receipt (Shop_Receipt) | receipt_id | Etsy 把订单叫 receipt |
| fulfillment | Receipt Shipment | shipment_id | 承运商 + 追踪在 shipment 上 |
| package | 无 | 合成,交易分组进包裹 | API 里没有包裹维度 |
| label | 经 Etsy 物流 / 伙伴集成 | 无公开面单购买实体 | 适配器把面单购买当外部动作 |
| tracking | Receipt Shipment: tracking_code、tracking_company | 映射到 package.tracking[] | 承运商 + 追踪经 createReceiptShipment 提交[^4] |
注意这些空缺里的规律。WooCommerce 没有原生的面单实体,Etsy 没有包裹维度,但适配器不会为了贴合而压平树;它合成缺失的实体,让下面每一层继续工作。这就是即使平台让你费劲,也要守住四实体模型的全部理由。
每一次集成评审里都会出现几个固定的失败模式。这里列出它们,以及防止每一种的建模决策。
tracking_number,订单第一次跨仓拆分或分三箱发货就崩。决策:把履约和包裹建模成真实实体,让追踪活在包裹上。fulfillment_id,账本只追加。如果这篇文章你只带走一样东西,就带走它产出的这张清单。把它当作任何 OMS、WMS 或 3PL 集成的评审闸门:
四个实体,五个决策,一张清单。这就是一套能扛住拆单、多段承运、作废风暴和月度对账的订单到面单数据模型的全部骨架。拿它建一次,以后每个新平台、新仓库、新承运商都只是一个适配器,而不是一次重设计。动手前可以用我们的API 上线就绪检查清单过一遍。
EasyShippingX 的 Shipping API 工作在承运商执行层(费率、面单、追踪),把订单、履约、包裹、面单和费用保持在可对账的同一链条上:幂等 key 防重复扣费、作废自动冲销、追踪按包裹分段、账单可上卷回订单和客户。四个实体、五个决策、一张清单,正是把「订单到面单」变成一套可审计数据模型所需的基础设施。看看定价或约一次演示。
不能直接假设。它是逻辑参考,实际字段、状态、权限和费用语义必须映射到当前版本的供应商文档与账户功能,并通过测试确认。