# 自有车队取件与配送

本专题介绍商户账号的本地配送 API：由商户自有司机派送给收件人的订单（`type` `D`），或从寄件人处取件的订单（`type` `P`）。同一组接口可对这两类停靠点进行询价、下单、打印面单、追踪和取消，并通过 Webhook 将每次变更通知您的系统。本专题面向向商户自有车队派发任务的订单管理系统、ERP 和网店的开发人员。

## 1. 可以构建的应用

以下示例均以同一家商户为例：**Farine & Fils**，一家烘焙供应商，仓库位于 2200 Rue Cohen, Saint-Laurent, QC (H4R 2N6)，向蒙特利尔岛各地配送批发订单，并回收客户退还的空面包箱。一次典型配送是为 Café Lumière（5400 Avenue du Parc, Montréal (H2V 4G7)）配送一摞 12 kg、60 × 40 × 30 cm 的面包箱，批发订单号为 `WHS-20931`。一次典型取件是从 Épicerie Wellington（4100 Rue Wellington, Verdun (H4G 1V5)）收取一摞 4 kg 的空箱，参考号为 `CRT-20931`。

- **由 ERP 派发的批发订单。** 每张已确认的批发订单成为一张配送订单，带有咖啡馆的上午送货时间窗，ERP 将返回的运单号保存在订单行上。
- **面包箱回收取件。** 客户报告有空箱时，ERP 为客户地址创建取件订单，由司机在下一条路线上收取。
- **仓库面单打印。** ERP 下载每张订单的面单 PDF 并在装货口打印，使每摞面包箱都贴有追踪条码。
- **显示实时状态的客户门户。** 每家咖啡馆都能查看其配送和取件的状态以及签收凭证，数据由 Webhook 推送，无需轮询。

## 2. 本专题的适用范围

当订单由商户自有司机运送时使用本专题：从仓库出发的配送，以及从客户地址取件，通过 `/api/v1/client/...` 和 `/api/v1/orders/...` 接口逐单或批量创建。

对于新集成，推荐使用 Uniorder（`/api/v1/uniorder/...`）作为统一入口：它通过一套 API 提供同样的自有车队配送，并与承运商面单一起通过同一次询价返回。概述见 **Uniorder：所有货件统一一个接口**，逐步请求见 **一次询价与下单**。本专题中的接口对基于它们构建的集成继续可用且保持不变。

包裹由外部承运商以通过平台购买的面单运送时，请使用 **承运商打单**。客户账号按商户的服务下单时，请使用 **运输服务**；货物存放在仓库中并按请求出库时，请使用 **仓储与出库**；Uniorder 不适用于这两类业务。

## 3. 开始之前

- **账号。** 使用具有 API 权限的商户（客户）账号或该商户的员工账号。创建订单还需要下单权限；没有该权限时，`POST /api/v1/client/orderCreate` 返回 `401`。
- **服务区域。** 配送或取件地址必须位于商户的有效区域内。测试时请使用本专题中这类区域内的地址。
- **测试数据。** 使用 `WHS-20931`、`CRT-20931` 等测试参考号，并在最后取消测试订单（第 12 步）。
- **令牌。** 从您的服务器请求访问令牌并保存在服务器上。切勿将其发送到浏览器或移动应用。
- **占位符。** 将 `YOUR_HOST` 替换为您所在环境的 API 主机，将 `ACCESS_TOKEN` 替换为第 4 步获得的令牌。
- **单位。** `weight_unit`：`1` 克，`2` 千克，`3` 盎司，`4` 磅。`dimension_unit`：`1` 毫米，`2` 厘米，`3` 米，`4` 英寸。两者默认均为 `1`。

## 4. 登录

除公开追踪外，本专题中的每个调用都以商户账号的身份发起。请从服务器登录一次，保存返回的令牌，并在每个请求中携带。

**REST：** `POST /api/v1/user/login` — [REST 手册](/api/documentation#/paths/v1-user-login/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/user/login \
  -H "Content-Type: application/json" \
  -d '{"email":"dispatch@farineetfils.example","password":"your_password"}'
```

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
}
```

- `access_token`：将其置于后续每个请求的请求头中：

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL 请求提交至 `POST /api/graphql`，并使用相同的请求头。

**GraphQL：** `userLogin`（[GraphQL 手册](/api/graphql/documentation#/user/userLogin)）

**验证：** 登录成功后返回 `access_token`。后续请求若未携带该令牌，将返回 `401`。

## 5. 对配送或取件询价（可选）

询价可在订单创建前显示某个停靠点的价格，例如在批发发票上显示配送费。询价不创建任何内容，创建订单也不要求事先询价。将 `type` 设为 `D`（配送）或 `P`（取件）；`to_postcode` 为该停靠点的邮编。

**REST：** `POST /api/v1/orders/rate` — [REST 手册](/api/documentation#/paths/v1-orders-rate/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/orders/rate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "D",
    "from_postcode": "H4R2N6",
    "from_country": "CA",
    "to_postcode": "H2V4G7",
    "to_country": "CA",
    "packages": [{
      "weight": 12,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }]
  }'
```

```json
{
  "result": true,
  "shipping_price": "18.50",
  "currency": "CAD",
  "price_details": {
    "shipping_fee": "18.50",
    "tax_details": [
      { "tax_name": "GST", "tax_rate": "5.00", "tax": "0.93" },
      { "tax_name": "QST", "tax_rate": "9.975", "tax": "1.85" }
    ]
  }
}
```

- `shipping_price`：该停靠点的税前价格。价格为空表示该邮编不在有效区域内，或价目表中没有对应的行。
- `price_details.tax_details`：订单将产生的税费；请在发票行上显示。
- `currency`：响应中所有金额的币种。

如需对面包箱取件询价，请发送相同的请求，并使用 `"type": "P"`、`"to_postcode": "H4G1V5"` 以及该摞面包箱的重量和尺寸。

**GraphQL：** `ordersRate`（[GraphQL 手册](/api/graphql/documentation#/orders/ordersRate)）。结果为 JSON 标量，不需要选择集。

```graphql
mutation {
  ordersRate(
    type: "P"
    from_postcode: "H4R2N6"
    from_country: "CA"
    to_postcode: "H4G1V5"
    to_country: "CA"
    packages: [{ weight: 4, weight_unit: 2, length: 60, width: 40, height: 30, dimension_unit: 2 }]
  )
}
```

**验证：** 无论 `type` `D` 还是 `type` `P`，`result` 均为 `true`，且 `shipping_price` 为数字。创建订单不依赖本步骤。

## 6. 创建配送订单

每张已确认的批发订单成为一张配送订单。ERP 将返回的 `id` 和 `tracking_number` 保存在其订单行上；之后的每个调用都使用其中之一。

**REST：** `POST /api/v1/client/orderCreate` — [REST 手册](/api/documentation#/paths/v1-client-orderCreate/post)

请发送 `Idempotency-Key` 请求头，每张批发订单使用唯一值，以免超时后的重试创建第二张订单。

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: whs-20931-delivery" \
  -d '{
    "type": "D",
    "need_pick_up": 0,
    "ref": "WHS-20931",
    "name": "Marie Tremblay",
    "company_name": "Café Lumière",
    "telephone": "5145550142",
    "email": "commandes@cafelumiere.example",
    "address_1": "5400 Avenue du Parc",
    "city": "Montréal",
    "province": "QC",
    "postcode": "H2V4G7",
    "country": "Canada",
    "schedule_date": "2026-10-02",
    "time_window_start": "2026-10-02 06:00:00",
    "time_window_end": "2026-10-02 09:00:00",
    "packages": 1,
    "packagesDetail": [{
      "ref": "WHS-20931-1",
      "weight": 12,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }],
    "delivery_instruction": "Deliver to the back door on Rue Saint-Viateur"
  }'
```

```json
{
  "result": true,
  "id": 12345,
  "ref": "WHS-20931",
  "shipping_price": "18.50",
  "currency": "CAD",
  "price_details": { "shipping_fee": "18.50" },
  "tracking_number": ["SR123456789012"],
  "packages": [
    { "id": 67890, "ref": "WHS-20931-1", "tracking_number": "SR123456789012", "external_tracking_number": "" }
  ]
}
```

| 字段 | 含义 |
|---|---|
| `type` | `D` 配送，或 `P` 取件 |
| `need_pick_up` | `0` — 货物已在仓库。`1` — 需要司机上门取件 |
| `ref` | 用于查询和对账的外部参考号 |
| `name` / 地址 | 配送：收件人。取件：取件停靠点 |
| `schedule_date`, `time_window_start`, `time_window_end` | 配送日期（`Y-m-d`）以及必须完成该停靠点的时间窗（`Y-m-d H:i:s`） |
| `packagesDetail` | 每个包裹一项；`ref` 标识该包裹在您系统中的记录 |
| `auto_deduplication` | `1` 拒绝包裹 `ref` 相同的第二个包裹 |

响应中：

- `id`：订单 id；请保存，用于订单详情和取消调用。
- `tracking_number`：每个包裹一个运单号；用于打印和追踪。
- `warning`：订单创建时附带提示时出现，例如地址位于配送区域外但商户选择保留或暂扣该订单。保留的区域外订单可能返回 `shipping_price: null`。

**GraphQL：** `clientOrderCreate`（[GraphQL 手册](/api/graphql/documentation#/client/clientOrderCreate)）。结果为 JSON 标量，内容与 REST 响应相同。

```graphql
mutation {
  clientOrderCreate(
    type: "D"
    need_pick_up: 0
    ref: "WHS-20931"
    name: "Marie Tremblay"
    company_name: "Café Lumière"
    telephone: "5145550142"
    address_1: "5400 Avenue du Parc"
    city: "Montréal"
    province: "QC"
    postcode: "H2V4G7"
    country: "Canada"
    packages: 1
    packagesDetail: [{ ref: "WHS-20931-1", weight: 12, weight_unit: 2, length: 60, width: 40, height: 30, dimension_unit: 2 }]
  )
}
```

**验证：** 使用相同的 `Idempotency-Key` 再次发送相同的请求体。响应带有相同的 `id`，且不会创建第二张订单。

## 7. 创建取件订单

取件订单派司机到某地址收取货物；此处为 Épicerie Wellington 的空面包箱。它与配送使用同一接口：地址为取件停靠点，`type` 为 `P`，`need_pick_up` 为 `1`。

**REST：** `POST /api/v1/client/orderCreate` — [REST 手册](/api/documentation#/paths/v1-client-orderCreate/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: crt-20931-pickup" \
  -d '{
    "type": "P",
    "need_pick_up": 1,
    "ref": "CRT-20931",
    "name": "Luc Gagnon",
    "company_name": "Épicerie Wellington",
    "telephone": "5145550187",
    "email": "luc@epiceriewellington.example",
    "address_1": "4100 Rue Wellington",
    "city": "Verdun",
    "province": "QC",
    "postcode": "H4G1V5",
    "country": "Canada",
    "packages": 1,
    "packagesDetail": [{
      "ref": "CRT-20931-1",
      "weight": 4,
      "weight_unit": 2,
      "length": 60,
      "width": 40,
      "height": 30,
      "dimension_unit": 2
    }],
    "pickup_instruction": "Empty crates are stacked at the loading door"
  }'
```

```json
{
  "result": true,
  "id": 12346,
  "ref": "CRT-20931",
  "shipping_price": "12.00",
  "currency": "CAD",
  "tracking_number": ["SR123456789029"],
  "packages": [
    { "id": 67891, "ref": "CRT-20931-1", "tracking_number": "SR123456789029", "external_tracking_number": "" }
  ]
}
```

- `id` 和 `tracking_number`：与配送相同，将其与该次面包箱回收关联保存。
- `pickup_instruction`：在取件停靠点向司机显示；`delivery_instruction` 是配送订单上对应的字段。

**验证：** 该订单的订单详情（第 8 步）显示 `type` `P` 和 `need_pickup` `1`。

## 8. 查询订单

订单详情用于确认已保存的内容并返回当前状态；列表接口供 ERP 将自身记录与平台对账。

**REST：** `GET /api/v1/orders/{orderId}` — [REST 手册](/api/documentation#/paths/v1-orders-orderId/get)

```bash
curl https://YOUR_HOST/api/v1/orders/12345 \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "business_name": "Farine & Fils",
  "order": {
    "id": 12345,
    "ref": "WHS-20931",
    "type": "D",
    "need_pickup": 0,
    "orders_status_id": 2,
    "name": "Marie Tremblay",
    "address_1": "5400 Avenue du Parc",
    "postcode": "H2V4G7",
    "time_window_start": "2026-10-02 06:00:00",
    "time_window_end": "2026-10-02 09:00:00",
    "shipping_price": "18.50"
  },
  "tracking_numbers": ["SR123456789012"],
  "packages": [
    { "id": 67890, "ref": "WHS-20931-1", "tracking_number": "SR123456789012" }
  ]
}
```

- `order.orders_status_id`：订单状态；`2` 为新建，`12` 为已取消。
- `order.type` 和 `order.need_pickup`：确认该停靠点是按配送还是取件保存的。
- `tracking_numbers`：该订单各包裹的运单号。

**REST：** `GET /api/v1/orders/list` — [REST 手册](/api/documentation#/paths/v1-orders-list/get)

```bash
curl "https://YOUR_HOST/api/v1/orders/list?page=1&per_page=50" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

列表返回该账号的全部订单，最新的在前，每张订单都带有其包裹和货物明细行。同时传入 `page` 和 `per_page` 即可分页（`per_page` 最大 1000）；不传时返回最新的 1000 张订单，并附带 `truncated` 标记。

**GraphQL：** 单张订单使用 `orders`（[GraphQL 手册](/api/graphql/documentation#/orders/orders)），列表使用 `ordersList`（[GraphQL 手册](/api/graphql/documentation#/orders/ordersList)）。两者均返回 JSON 标量。

```graphql
query {
  orders(orderId: "12345")
}
```

**验证：** 订单属于当前已认证的账号，`ref` 与创建时发送的值一致，`tracking_numbers` 与创建响应一致。

## 9. 打印本地面单

面单带有司机在仓库和停靠点扫描的追踪条码。每个包裹打印一张面单，并贴在面包箱上。

**REST：** `POST /api/v1/shipping/getShippingLabel` — [REST 手册](/api/documentation#/paths/v1-shipping-getShippingLabel/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/shipping/getShippingLabel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "SR123456789012",
    "type": "TRACKING_NUMBER",
    "base64": 1,
    "hide_sender_address": 0,
    "hide_receiver_address": 0
  }'
```

```json
"JVBERi0xLjcKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZwo..."
```

- `type`：`id` 的解读方式：`TRACKING_NUMBER`（默认）、`ORDER_ID` 或 `REF`。
- `base64`：`0`（默认）直接输出 PDF 流。`1` 使整个响应体成为一个顶层 JSON 字符串，内容为 base64 编码的 PDF，而不是带有 `pdf_data` 字段的对象。如需在常规 JSON 对象中接收面单，请改为调用 `POST /api/v2/shipping/getShippingLabel` — [REST 手册](/api/documentation#/paths/v2-shipping-getShippingLabel/post)。
- `packages`：可选；要打印的面单数量。该值与订单的包裹数不同时，会更新订单。
- `hide_sender_address` / `hide_receiver_address`：`1` 使面单上的该地址留空。

**GraphQL：** `shippingGetShippingLabel`（[GraphQL 手册](/api/graphql/documentation#/shipping/shippingGetShippingLabel)）。`shippingGetShippingLabelV2`（[GraphQL 手册](/api/graphql/documentation#/shipping/shippingGetShippingLabelV2)）始终返回 JSON（`pdf_data`）。

**验证：** 解码后的 PDF 可以打开。配送面单显示 Café Lumière 的地址；取件面单显示 Épicerie Wellington 的地址。被隐藏的地址在面单上留空。

## 10. 追踪订单

公开追踪返回包裹的事件时间线。它不需要访问令牌，因此客户门户可直接显示；配送或取件的签收凭证随之返回。

**REST：** `GET /api/v1/tracking/{trackingNumber}` — [REST 手册](/api/documentation#/paths/v1-tracking-trackingNumber/get)

```bash
curl https://YOUR_HOST/api/v1/tracking/SR123456789012
```

```json
{
  "result": true,
  "postcode": "H2V4G7",
  "deliveried": false,
  "returntosender": false,
  "rejectedbyrecipient": false,
  "data": [
    {
      "tracking_event_status_id": 100,
      "description": "Order information submitted",
      "updated_at_localized": "2026-10-01 16:42:10"
    }
  ],
  "proofs": []
}
```

当您的 `ref` 已作为外部单号保存时，同一 URL 也接受该值。

请根据 `tracking_event_status_id` 进行分支判断，而不是 `description`；该字符串随 `Accept-Language` 变化。

| `tracking_event_status_id` | 侧 | 含义 |
|---|---|---|
| `100` | 双方 | 已收到订单 |
| `300` / `301` | 配送 | 在仓 |
| `450` | 配送 | 派送中 |
| `500` | 配送 | 已送达 |
| `501` | 配送 | 配送失败，需要重新安排 |
| `460` | 取件 | 取件途中 |
| `510` | 取件 | 已取件 |
| `512` | 取件 | 取件失败，稍后重试 |
| `513` | 取件 | 取件异常 |

- `data`：最新的在前；第一行为当前状态。
- `deliveried`：`500` 之后为 `true`。
- `proofs[]`：在 `500` 或 `510` 时，可能包含 `type` `1`（签名）或 `2`（照片），并带有 `file_id` 和 `signed_url`。该事件之后上传的照片不在此数据中；请订阅 `pod.files_updated`（第 11 步）。

**GraphQL：** `trackingPublic`（[GraphQL 手册](/api/graphql/documentation#/tracking/trackingPublic)）。结果为带类型的对象，需要选择集。

```graphql
query {
  trackingPublic(trackingNumber: "SR123456789012") {
    result
    deliveried
    returntosender
    rejectedbyrecipient
    postcode
    data {
      tracking_event_status_id
      tracking_event_key
      description
      updated_at_localized
    }
    proofs { file_id type full_url signed_url }
  }
}
```

**验证：** 创建后立即查询，最新事件为 `100`，`deliveried` 为 `false`。未知单号返回 `result: false` 及 `404`；请显示未找到状态，不要自行生成追踪事件。

## 11. 接收 Webhook

Webhook 将每次变更推送到您的服务器，使 ERP 和客户门户无需轮询即可保持最新。请注册本流程所需的回调 URL：

| 设置项 | 事件 | 用途 |
|---|---|---|
| `order_create_webhook_url` | `order.created` | 保存 `id` 和 `tracking_number` |
| `order_status_change_webhook_url` | `order.status_change` | 面向客户的状态 |
| `tracking_event_webhook_url` | `tracking.event` | 取件或配送时间线 |
| `pod_files_webhook_url` | `pod.files_updated` | 取件或配送后的照片或签名 |
| `order_cancel_failed_webhook_url` | `order.cancel_failed` | 您发送的取消请求被拒绝 |
| `order_create_async_postback_url` | `order.create_async` | 异步批量任务的结果（第 13 步） |

**REST：** `PUT /api/v1/webhook-settings` — [REST 手册](/api/documentation#/paths/v1-webhook-settings/put)

```bash
curl -X PUT https://YOUR_HOST/api/v1/webhook-settings \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "order_create_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "order_status_change_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "tracking_event_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "pod_files_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "order_cancel_failed_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "webhook_sign_secret": "YOUR_LONG_RANDOM_SECRET",
    "webhook_verify_ssl": 1
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "order_create_webhook_url",
    "order_status_change_webhook_url",
    "tracking_event_webhook_url",
    "pod_files_webhook_url",
    "order_cancel_failed_webhook_url",
    "webhook_sign_secret"
  ],
  "settings": {
    "webhook_sign_secret": "************CRET",
    "tracking_event_webhook_url": "https://erp.farineetfils.example/hooks/superroute",
    "webhook_verify_ssl": 1
  }
}
```

- `changed_keys`：本次调用更改的设置项。
- `settings.webhook_sign_secret`：以掩码形式返回；完整值仅保存在您的服务器上。

**GraphQL：** `webhookSettingsUpdate`（[GraphQL 手册](/api/graphql/documentation#/webhooks/webhookSettingsUpdate)）。

在接收端，对原始请求体校验 **v2** 签名：计算 `HMAC_SHA256(timestamp + "." + raw_body, secret)` 并与 `X-Webhook-Signature-V2` 比对，其中 timestamp 为 `X-Webhook-Timestamp`。按 `X-Webhook-Event-Id` 去重。请在 **3 秒内返回 2xx**，之后再处理事件。

```php
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE_V2'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $raw, $sharedSecret);
if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit;
}
```

**验证：** 创建一张测试订单，收到带有相同 `id` 和 `tracking_number` 的 `order.created`。接收端以 `401` 拒绝无效签名，且同一 `X-Webhook-Event-Id` 的重复投递不会被处理两次。

## 12. 取消订单

批发订单撤回或面包箱取件不再需要时，取消该订单。该调用是幂等的：对已取消的订单再次取消同样成功。

**REST：** `POST /api/v1/orders/cancel` — [REST 手册](/api/documentation#/paths/v1-orders-cancel/post) — `order_id`、`tracking_number`、`external_tracking_number` 三者只能且必须传入一个。

```bash
curl -X POST https://YOUR_HOST/api/v1/orders/cancel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tracking_number":"SR123456789012"}'
```

```json
{
  "result": true,
  "id": 12345,
  "message": "Order 12345 has been cancelled successful.",
  "already_cancelled": false
}
```

- `result`：订单已取消时为 `true`。
- `already_cancelled`：订单在本次调用之前已被取消时为 `true`；按成功处理。
- `code`：取消被拒绝时出现；见第 14 步。

**GraphQL：** `ordersCancel`（[GraphQL 手册](/api/graphql/documentation#/orders/ordersCancel)）。结果为带类型的对象，需要选择集。

```graphql
query {
  ordersCancel(tracking_number: "SR123456789012") {
    result
    id
    message
    already_cancelled
    code
  }
}
```

**验证：** 订单详情显示 `orders_status_id` `12`，再次发送相同的取消请求返回 `already_cancelled: true`。取消被拒绝时，会向 `order_cancel_failed_webhook_url` 发送 `order.cancel_failed`。

## 13. 批量创建订单（可选）

ERP 可以在一个请求中发送当天的批发订单和面包箱取件。每一行使用与第 6 步和第 7 步相同的字段，可以是 `type` `D` 或 `P`。

**REST：** `POST /api/v1/client/batchOrderCreate` — [REST 手册](/api/documentation#/paths/v1-client-batchOrderCreate/post) — 所有行处理完毕后返回。

```bash
curl -X POST https://YOUR_HOST/api/v1/client/batchOrderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: batch-2026-10-01" \
  -d '{
    "per_order_transaction": 1,
    "orders": [
      {
        "type": "D",
        "need_pick_up": 0,
        "ref": "WHS-20932",
        "name": "Sophie Roy",
        "company_name": "Boulangerie du Marché",
        "telephone": "5145550163",
        "address_1": "7070 Avenue Henri-Julien",
        "city": "Montréal",
        "province": "QC",
        "postcode": "H2S3S3",
        "country": "Canada",
        "packages": 1,
        "packagesDetail": [{ "ref": "WHS-20932-1", "weight": 10, "weight_unit": 2, "length": 60, "width": 40, "height": 30, "dimension_unit": 2 }]
      },
      {
        "type": "P",
        "need_pick_up": 1,
        "ref": "CRT-20932",
        "name": "Luc Gagnon",
        "company_name": "Épicerie Wellington",
        "telephone": "5145550187",
        "address_1": "4100 Rue Wellington",
        "city": "Verdun",
        "province": "QC",
        "postcode": "H4G1V5",
        "country": "Canada",
        "packages": 1,
        "packagesDetail": [{ "ref": "CRT-20932-1", "weight": 4, "weight_unit": 2, "length": 60, "width": 40, "height": 30, "dimension_unit": 2 }]
      }
    ]
  }'
```

```json
[
  { "result": true, "id": 12347, "ref": "WHS-20932", "tracking_number": ["SR123456789036"], "packages": [{ "id": 67892, "ref": "WHS-20932-1", "tracking_number": "SR123456789036", "external_tracking_number": "" }] },
  { "result": true, "id": 12348, "ref": "CRT-20932", "tracking_number": ["SR123456789043"], "packages": [{ "id": 67893, "ref": "CRT-20932-1", "tracking_number": "SR123456789043", "external_tracking_number": "" }] }
]
```

- 每一行都有各自的 `result`；按 `ref` 与您的订单行对应。被拒绝的行带有 `message` 和 `skipped_ref`，并可能带有 `code`（例如 `INSUFFICIENT_BALANCE` 或 `OUT_OF_DELIVERY_AREA`）。
- `per_order_transaction`：`1` 使每一行单独提交，一行失败不会回滚其他行。
- 超过 100 张订单的批量请求会收到 `X-Batch-Size-Warning` 响应头；请将其发送至异步接口。

**REST：** `POST /api/v1/client/batchOrderCreateAsync` — [REST 手册](/api/documentation#/paths/v1-client-batchOrderCreateAsync/post) — 使用相同的请求体，并立即返回任务标识：

```json
{ "message": "Order batch created in async,please check later.", "asyncId": 28 }
```

使用 `asyncId` 轮询 `GET /api/v1/client/async/{id}` — [REST 手册](/api/documentation#/paths/v1-client-async-id/get)，或在 `order_create_async_postback_url` 接收 `order.create_async`。任务结果与同步接口相同，为逐行列表。

```bash
curl https://YOUR_HOST/api/v1/client/async/28 \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

**GraphQL：** `clientBatchOrderCreate`（[GraphQL 手册](/api/graphql/documentation#/client/clientBatchOrderCreate)）、`clientBatchOrderCreateAsync`（[GraphQL 手册](/api/graphql/documentation#/client/clientBatchOrderCreateAsync)）和 `clientAsync`（[GraphQL 手册](/api/graphql/documentation#/client/clientAsync)）。

**验证：** 两行的批量请求返回两条结果，每条带有其 `ref`。异步任务执行完成后返回相同的行。

## 14. 错误处理

| 情形 | HTTP 状态 | 代码 | 集成方的处理 |
|---|---|---|---|
| 必填字段缺失或格式错误（创建） | 400 | `VALIDATION_FAILED` | 更正 `message` 中指出的字段后重新发送请求。 |
| 账户余额不足以支付该订单 | 400 | `INSUFFICIENT_BALANCE` | 读取 `insufficient_balance`（所需金额、可用金额、差额）；充值后重试。未创建任何订单。 |
| 地址位于服务区域外，且商户会删除此类订单 | 400 | `OUT_OF_DELIVERY_AREA` | 提交服务区域内的地址。未创建任何订单。 |
| 包裹 `ref` 或外部运单号已存在（已开启去重） | 200（`result` `false`），或在 `strict_duplicate_check` `1` 时为 409 | `DUPLICATE_TRACKING_NUMBER` | 读取 `exist_package_ref`，关联已有订单，而不是创建新订单。 |
| `Idempotency-Key` 被用于不同的请求体 | 409 | `IDEMPOTENCY_CONFLICT` | 不同的请求使用新的键。 |
| 使用相同 `Idempotency-Key` 的请求仍在处理中 | 409 | `IDEMPOTENCY_IN_PROGRESS` | 等待后使用相同的键重试。 |
| 取消时未提供订单标识 | 400 | `MISSING_IDENTIFIER` | 发送 `order_id`、`tracking_number`、`external_tracking_number` 之一。 |
| 取消的订单不存在 | 400 | `ORDER_NOT_FOUND` | 检查已保存的 `id` 或运单号。 |
| 该单号匹配到多张有效订单 | 409 | `MULTIPLE_ORDERS_MATCHED` | 使用 `matched_order_ids` 中的一个，按 `order_id` 取消。 |
| 订单属于其他账号 | 401 | `ORDER_CANCEL_UNAUTHORIZED` | 使用创建该订单的账号取消。 |
| 订单当前状态已不允许取消 | 401 | `ORDER_STATUS_NOT_CANCELLABLE` | 保持订单现状；另行处理退货。 |
| 订单由无法取消的第三方承运商持有 | 409 | `ORDER_CANCEL_BLOCKED_THIRD_PARTY`, `THIRD_PARTY_CANCEL_NOT_SUPPORTED` 或 `THIRD_PARTY_CANCEL_FAILED` | 订单未更改；请联系商户。 |
| 令牌缺失或已过期，或账号无下单权限 | 401 | — | 重新登录；检查账号权限。 |

## 验收清单

请使用 `WHS-20931`、`CRT-20931` 等测试参考号：

- [ ] （可选）对区域内邮编使用 `type` `D` 询价时返回价格。
- [ ] （可选）对区域内邮编使用 `type` `P` 询价时返回价格。
- [ ] 创建配送订单返回 `id` + `tracking_number`；相同的 `Idempotency-Key` 不会创建第二张订单。
- [ ] 创建取件订单返回 `id` + `tracking_number`；订单详情显示 `type` `P` 和 `need_pickup` `1`。
- [ ] 订单详情和列表均在本账号下显示这两张订单。
- [ ] 本地面单 PDF 可以打开，并显示收件人或取件地址。
- [ ] 公开追踪无需令牌即可返回时间线；最新事件为 `100`。
- [ ] 收到 `order.created`，且其 v2 签名校验通过。
- [ ] 取消返回 `result: true`，再次取消返回 `already_cancelled: true`。
- [ ] 包含一张配送和一张取件的批量请求返回两条结果，每条带有其 `ref`。
