# 自有車隊取件與配送

本專題介紹商戶帳號的本地配送 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`。
