电商物流指南

订单 vs 发货 vs 包裹 vs 面单:写给 OMS、WMS 与 3PL API 的物流数据模型

OMS、WMS、3PL 与承运商之间每一次集成,最终都会撞上同一个词汇问题:一个系统叫它「订单」,另一个叫它「发货」,第三个坚持叫「包裹」,而承运商要的是一张带追踪号的「面单」。这些词看起来可以互换,而一旦被当成同义词对待,bug 就跟着来了:重复发货、追踪号丢失、对账永远对不平。

编辑插画:四个大小递减的瓦楞纸箱像套娃一样层层嵌套在仓库工作台上,最小的那只贴着白色面单,墨线画风配橙色点缀,留白充足,构图不对称
四个实体,像套娃一样层层嵌套:订单里是发货,发货里是包裹,包裹上是面单

一个订单,四个实体:一次示例发货

先看一个具体的示例订单,让抽象概念有个安身之处。在这个示例里,客户在一次结账中买了三件商品:两个陶瓷马克杯和一件棉 T 恤,订单总额 170.11 美元。库存分散在两个仓库,OMS 决定马克杯从东岸仓库发货,T 恤从西岸仓库发货。

  • 09:14 订单创建,id 为 SHOP-1001,状态 placed
  • 09:20 OMS 把订单拆成两个履约,每个仓库一个
  • 13:52 东岸仓库把两个马克杯装进一个箱子,包裹 P1
  • 14:05 东岸为 P1 购买 DHL 面单,拿到第一个追踪号,履约状态翻转为 shipped
  • 15:02 在示例里,客户追加保价;一笔 6.20 美元的调整记录到东岸履约下
  • 16:30 西岸仓库打包 T 恤,包裹 P2,购买 USPS 面单
  • 16:44 因地址更正,西岸面单作废,记一笔冲销,购买替换面单
  • 第 3 天 P1 清关后转交 USPS 走最后一公里,第二个追踪号挂到 P1 上
  • 第 5 天两个包裹均送达,订单作为 fully fulfilled 关闭

注意刚刚发生了什么。一个订单产生了两个履约(每个仓库一个)、两个包裹(每个箱子一个)、三个追踪号,因为 P1 走了两段、用了两家承运商。追踪号挂在包裹上,不挂在订单上。如果你在订单行上开一列「tracking_number」,这三个号里你打算填哪个?

这个问题就是本文的论点:1 个订单 → N 个履约 → N 个包裹 → N 张面单,追踪归属包裹。后面所有内容,都是为了让这条层级结构可以安全地承载业务:防止重复发货、永不丢失追踪号、每一分钱都能追溯回订单和客户。

四个实体与一张与厂商无关的关系树

四个实体,四个不同的职责。把任何两个搞混,集成就会开始腐烂。

  • 订单(Order):商业承诺。客户同意购买并支付的东西。活在 OMS 或 ERP 里,有订单行、合计和客户。
  • 履约(Fulfillment,又名 Shipment):履约动作。谁在什么时候履约什么。这是商业世界和物流世界之间的接缝。一个订单拆成 N 个履约,通常按仓库或履约服务商拆。
  • 包裹(Package):物理容器。真正离开仓库的那个箱子,有重量和尺寸。承运商扫描和搬运的就是它。
  • 面单(Label):承运商票据。承运一票包裹的权利凭证,带着面单号和追踪号。它既是票据也是金融工具,所以作废一张面单必须动钱,而不只是改状态。

与厂商无关的关系树:

Order
└── Fulfillment / Shipment
    ├── Package
    │   ├── Label
    │   └── Tracking Number
    └── Charges / Adjustments / Reversals
四实体关系图:订单拆分出履约,履约打包出包裹并产生费用,包裹携带面单、经由追踪,墨线加橙色点缀
Order → Fulfillment → Package → Label / Tracking,外加挂在履约下的费用

这棵树里有两条关系最值得强调,因为它们是大家最爱压平掉的东西。第一,一个履约可以产出多个包裹:三箱的订单是一个履约,不是三个。第二,一个包裹可以带多个追踪号:DHL 飞到边境、再转 USPS 送到门口的包裹有两段,每段都有自己的承运商和追踪号。如果你直接建模「order → tracking」,这两个事实都会消失。

为什么第二层叫「Fulfillment」而不是「Shipment」?因为平台们口径不一:Shopify 叫 fulfillment,Amazon 把 FBA 批次叫 shipment,多数 WMS 把每次派送都叫 shipment。树必须保持与厂商无关,所以这一层就是 fulfillment/shipment 这个概念本身,不管某个平台怎么叫。你的适配器把平台的名字映射到这个节点就停了,树的其他部分永远不变。想深入理解为什么订单状态、发货状态和追踪状态总是对不上,可以读姊妹篇订单状态 vs 发货状态 vs 追踪状态。

核心字段与状态机

每个实体都需要一套小而稳定的字段。下面是我们建议的最小集,每个实体一张表。

实体 字段 用途
orderid规范 UUID
orderexternal_reference平台订单号(SHOP-1001),用于关联
ordercustomer_id商业承诺的归属客户
orderline_items承诺了哪些商品
ordertotals以订单币种计收了多少钱
orderstatus承诺的生命周期
fulfillmentid规范 UUID
fulfillmentorder_id回指承诺
fulfillmentwarehouse_id由谁履约
fulfillmentstatus动作的生命周期
fulfillmentidempotency_key防止重复履约
fulfillmentcreated_at动作开始时间
packageid规范 UUID
packagefulfillment_id由哪个动作产出
packageweight, dimensions承运商按什么计费
packagetracking分段数组,每段一个
packagestatus箱子的生命周期
labelid规范 UUID
labelpackage_id授权的是哪个箱子
labelcarrier哪家承运商签发
labellabel_format, label_urlPDF 存放在哪
labelstatuspurchased / reprinted / voided
labelcost金额 + 币种,供计费用
chargeid规范 UUID
chargefulfillment_id由哪个动作产生
chargecharge_typeshipping / adjustment / reversal
chargeamount, currency带符号的资金变动
chargereason为什么,供对账用
chargeapplied_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 是终态,并且总是伴随一笔冲销(详见下面的设计决策)。

一套可落地的 JSON 模型

先定约定,因为每个系统各写各的时候,示例根本没法读。字段名用 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:

  • 订单创建:给结账或 webhook 的双重提交去重。
  • 履约创建:WMS 重试时防止重复发货。
  • 面单购买:承运商调用超时时防止重复扣费。

模式处处相同。调用方生成一个 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 调用里完成:

  1. 告诉承运商面单已死,不能再被投递。
  2. 面单状态翻转为 voided,终态。
  3. 写一笔与原始金额镜像的负数 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 直接写订单状态。
  • 把 (carrier, tracking_number) 当唯一键并去重。当一个追踪号被多个系统轮询、或区域承运商转交全国承运商复用了同一号码时,会得到重复事件。追加前按 (carrier, tracking_number, event_code, timestamp) 去重,否则妥投数报表会悄悄出错。

复合键比看起来更重要。追踪号不是全局唯一的。「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,这个数字必须和承运商对两个包裹开出的账单一致。不一致时,差异会指向一个具体的履约和一笔具体的费用行,这正是把钱挂在履约而不是订单或客户上的全部意义。客户级计费是这些行的上卷,不是另一本账。

门户和 API 各自保留哪些 ID

产品每一层都应该存自己实际用到的 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 到承运商,重试、作废和多承运商节点都标在它们真实发生的位置:

时序图:客户下单,OMS 拆分为两个履约,WMS 打包并购买面单,含幂等重试分支、作废冲销分支与多段追踪事件
Customer → OMS → WMS → Carrier 的完整握手,含重试、作废与第二段追踪

这段时间线里有三个时刻是前面决策兑现回报的地方。alt 分支是幂等 key 在干活:购买请求超时,重试复用同一个 key,承运商返回已存在的面单而不是扣两次钱。作废分支是冲销规则在起作用:面单死掉、钱被镜像、替换购买是一个干净的新面单 id。最后的妥投事件带着包裹第二段的追踪号,这之所以成立,只因为模型把追踪存到了包裹上,而不是订单上。

映射速查:Shopify、Amazon SP-API、WooCommerce、Etsy

与厂商无关的模型是你的中间层。每个平台配一个适配器,把它的概念映射到树上,方向永远一致:平台 → 中立模型。绝不做平台 → 平台。接入第五个平台时,你写一个适配器,而不是四条新的两两集成。

我们的实体 规范含义 规范字段 关联键
order商业承诺id、external_reference、customer_id、line_items、totals、statusid ↔ external_reference
fulfillment履约动作id、order_id、warehouse_id、idempotency_key、statusid ↔ 平台履约 id
package物理容器id、fulfillment_id、weight、dimensions、tracking[]、statusid ↔ 平台包裹 id
label承运商票据id、package_id、carrier、status、costid ↔ 承运商面单 id
charge资金变动id、fulfillment_id、charge_type、amount、currencyid ↔ 平台费用 id

Shopify

我们的实体 Shopify 概念 字段映射 备注
orderOrderorder.id、order.namename 是人读的订单号,如 #1001
fulfillmentFulfillment / FulfillmentOrderfulfillment.id、fulfillment_order.location_id追踪字段活在 Fulfillment 上[^1]
package无由 line_items + fulfillment 合成适配器把商品分组进包裹
label核心 API 无经 Shopify Shipping 或承运商应用label_url 存履约 metafields
trackingFulfillment 的 tracking_number、tracking_company、tracking_urls + FulfillmentEvent映射到 package.tracking[]一个履约可带多个追踪号

Amazon SP-API

我们的实体 SP-API 概念 字段映射 备注
orderOrders API: OrderAmazonOrderId
fulfillmentFBA: Fulfillment Shipment;MFN: Merchant Fulfillment ShipmentshipmentId两种履约模式是两套不同的 API[^3]
packageFBA: FulfillmentShipmentPackage;MFN: Shipment 上的包裹级追踪packageId
labelMFN: Merchant Fulfillment ShippingLabel;FBA: shipment labellabelDownloadURL购买面单是独立 API 调用(buyShipping)[^2]
trackingFBA: package tracking;MFN: trackingIdtrackingNumber、trackingId

WooCommerce

我们的实体 WooCommerce 概念 字段映射 备注
orderOrder (WC_Order)order.id、order.number
fulfillment无订单状态流转:processing → completed适配器把状态变更当履约
package无合成
label无插件或外部面单服务label_url 存 order meta
trackingShipment Tracking 插件:order meta 上的 tracking_number、tracking_provider、tracking_link映射到 package.tracking[]插件按订单挂 meta,不按包裹[^6]

Etsy

我们的实体 Etsy 概念 字段映射 备注
orderReceipt (Shop_Receipt)receipt_idEtsy 把订单叫 receipt
fulfillmentReceipt Shipmentshipment_id承运商 + 追踪在 shipment 上
package无合成,交易分组进包裹API 里没有包裹维度
label经 Etsy 物流 / 伙伴集成无公开面单购买实体适配器把面单购买当外部动作
trackingReceipt Shipment: tracking_code、tracking_company映射到 package.tracking[]承运商 + 追踪经 createReceiptShipment 提交[^4]

注意这些空缺里的规律。WooCommerce 没有原生的面单实体,Etsy 没有包裹维度,但适配器不会为了贴合而压平树;它合成缺失的实体,让下面每一层继续工作。这就是即使平台让你费劲,也要守住四实体模型的全部理由。

常见坑与建模决策

每一次集成评审里都会出现几个固定的失败模式。这里列出它们,以及防止每一种的建模决策。

  • 「一单等于一包」的假设。写进 schema 就是订单上一列 tracking_number,订单第一次跨仓拆分或分三箱发货就崩。决策:把履约和包裹建模成真实实体,让追踪活在包裹上。
  • 把追踪号当订单唯一键。多包裹订单有多个追踪号,多段包裹每个都有多个。拿追踪号当关联键会碰撞,或者更糟,悄悄只挑一个。决策:(carrier, tracking_number) 是唯一真正唯一的键,它标识一个包裹段,不是订单。
  • 缺履约幂等。WMS 重试,重试创建了第二个履约,同样的货发了两次。决策:每次履约创建都带调用方生成的幂等 key,服务端按 (key, entity_type) 去重。
  • 费用不挂履约。面单成本飘在订单或承运商账户上,月底没人能解释账单。决策:每笔费用、调整和冲销都带 fulfillment_id,账本只追加。
  • 把平台空缺当平台事实。WooCommerce 没有原生面单,Etsy 没有包裹维度,Shopify 核心 API 也没有面单实体。把模型压平到最弱平台的团队,会在其他所有平台上丢失追踪和计费的保真度。决策:守住中立四实体树,在适配器里合成缺失层。

如果这篇文章你只带走一样东西,就带走它产出的这张清单。把它当作任何 OMS、WMS 或 3PL 集成的评审闸门:

  1. 每个订单是否拆成一个或多个履约,每个履约是否知道自己的仓库?
  2. 每次履约创建是否都带幂等 key?
  3. 追踪是否按包裹存储,以 (carrier, tracking_number) 分段?
  4. 费用、调整和冲销是否都带 fulfillment_id,且没有任何费用被修改过?
  5. 能否把任何一美元从承运商账单追溯到履约、订单和客户?
  6. 面单作废是否总在给同一包裹买新面单之前先写一笔冲销?

四个实体,五个决策,一张清单。这就是一套能扛住拆单、多段承运、作废风暴和月度对账的订单到面单数据模型的全部骨架。拿它建一次,以后每个新平台、新仓库、新承运商都只是一个适配器,而不是一次重设计。动手前可以用我们的API 上线就绪检查清单过一遍。

下一步:让证据继续流转

把本阶段证据与未决项带到下一步。未确认的功能保持待核验,不用默认配置或更大业务量替代验证。

想按这套模型接入多承运商?

EasyShippingX 的 Shipping API 工作在承运商执行层(费率、面单、追踪),把订单、履约、包裹、面单和费用保持在可对账的同一链条上:幂等 key 防重复扣费、作废自动冲销、追踪按包裹分段、账单可上卷回订单和客户。四个实体、五个决策、一张清单,正是把「订单到面单」变成一套可审计数据模型所需的基础设施。看看定价或约一次演示。

常见问题

订单是商业承诺(客户同意买什么、付多少),发货/履约是履约动作(谁在什么时候履约什么),包裹是物理容器(真正离开仓库、承运商扫描的箱子),面单是承运商票据(承运一票包裹的权利凭证,带追踪号)。一个订单可以拆成多个履约,一个履约可以产出多个包裹,一个包裹可以带多个追踪号,一张面单只授权一个包裹。
三层都需要,每层各用自己的 key:订单创建(防结账/webhook 双提交)、履约创建(防 WMS 重试导致重复发货)、面单购买(防承运商超时导致重复扣费)。履约的 key 最常被忘,因为履约看起来像内部步骤,但它恰恰是移动真实箱子的那一步。
因为一个订单可以有多个包裹、一个包裹可以有多段运输:一单拆两仓发货就有两个包裹,国际包裹先走 DHL 再转 USPS 就有两个追踪号。追踪号不是全局唯一的,只有 (carrier, tracking_number) 复合键才是。追踪挂在订单上,schema 第一次遇到拆单或转交就崩了。
作废请求、资格确认、退款决定与信用到账分别记录,不能假设作废一定伴随已到账冲销。保留原费用与关联记录,实际收到的信用只冲减一次;替换面单的购买条件按实际服务核验。
不要把中立模型压平到最弱平台。适配器负责合成缺失的实体:Shopify 从 line_items + fulfillment 合成包裹、把 label_url 存进 metafields;WooCommerce 把状态流转当履约、把追踪 meta 映射到包裹段。中间层保持四实体,接入第五个平台只是多写一个适配器。
门户存人能读、能引用、能粘贴的:平台订单号、承运商印出来的追踪号、票据上的面单号。API 和系统记录存规范 UUID 加承运商原始 id。两层之间用一张映射表按 canonical_id ↔ external_reference ↔ portal display id 关联,绝不推断、绝不让展示 id 当主键。
本页 JSON 能直接作为供应商接口参数使用吗?

不能直接假设。它是逻辑参考,实际字段、状态、权限和费用语义必须映射到当前版本的供应商文档与账户功能,并通过测试确认。