# 自有車隊取件與配送

自有車隊上門取件與末端配送使用同一套介面。建立取件或配送訂單 → 列印本地面單 → 追蹤 → 接收事件通知 → 取消測試訂單。

`type` 決定停靠點：`D` 配送，`P` 取件。請按步驟順序呼叫。詢價為可選步驟，建立訂單前不必先詢價。

請將 `YOUR_HOST` 與 `ACCESS_TOKEN` 替換為實際環境中的值。

## 1. 登入

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

將回傳的 `access_token` 置於請求頭：

```
Authorization: Bearer ACCESS_TOKEN
```

GraphQL 請求提交至 `POST /api/graphql`，並使用相同的 Authorization 請求頭。

[REST 手冊](/api/documentation#/paths/v1-user-login/post) · [GraphQL 手冊](/api/graphql/documentation#/user/userLogin)

**驗證：** 登入成功後回傳 `access_token`。後續請求若未攜帶該權杖，將回傳 `401`。

## 2. 詢價（可選）

本步驟為可選。僅回傳報價，不建立訂單。建立訂單不依賴事先詢價。`type` 取 `D`（配送）或 `P`（取件）。

**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": "H9S2H9",
    "from_country": "CA",
    "to_postcode": "H4B2T5",
    "to_country": "CA",
    "packages": [{
      "weight": 1,
      "weight_unit": 2,
      "length": 30,
      "width": 20,
      "height": 10,
      "dimension_unit": 2
    }]
  }'
```

`weight_unit`：`1` 克，`2` 千克，`3` 盎司，`4` 磅。`dimension_unit`：`1` 毫米，`2` 厘米，`3` 米，`4` 英寸。

```json
{
  "result": true,
  "shipping_price": "6.99",
  "currency": "CAD",
  "price_details": {
    "shipping_fee": "6.99",
    "tax_details": [{ "tax_name": "HST", "tax_rate": "13.00", "tax": "0.91" }]
  }
}
```

**GraphQL**（JSON 標量，不要選擇集） (`ordersRate` ([GraphQL 手冊](/api/graphql/documentation#/orders/ordersRate)))：

```graphql
mutation {
  ordersRate(
    type: "D"
    from_postcode: "H9S2H9"
    from_country: "CA"
    to_postcode: "H4B2T5"
    to_country: "CA"
    packages: [{ weight: 1, weight_unit: 2, length: 30, width: 20, height: 10, dimension_unit: 2 }]
  )
}
```

**驗證：** 若執行本呼叫，`result` 為 true，且 `shipping_price` 是數字。可用 `type` `P` 再詢一次取件價格。未回傳價格說明郵遞區號不在有效服務範圍內。是否詢價不影響隨後建立訂單。

## 3. 建立訂單

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

請求須包含 `Idempotency-Key`，以避免重試時重複建立訂單。

### 配送（`type` `D`）

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: dev-local-001" \
  -d '{
    "type": "D",
    "need_pick_up": 0,
    "ref": "DEV-LOCAL-001",
    "name": "Jane Recipient",
    "telephone": "5555555555",
    "email": "jane@example.com",
    "address_1": "2667 boul des sources",
    "city": "Pointe-Claire",
    "province": "QC",
    "postcode": "H9S2H9",
    "country": "Canada",
    "packages": 1,
    "packagesDetail": [{
      "ref": "PKG-001",
      "weight": 1,
      "weight_unit": 2,
      "length": 30,
      "width": 20,
      "height": 10,
      "dimension_unit": 2
    }],
    "delivery_instruction": "放側門"
  }'
```

### 取件（`type` `P`）

同一介面。地址為取件點。

```bash
curl -X POST https://YOUR_HOST/api/v1/client/orderCreate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: dev-pickup-001" \
  -d '{
    "type": "P",
    "need_pick_up": 1,
    "ref": "DEV-PICKUP-001",
    "name": "Jane Sender",
    "telephone": "5555555555",
    "email": "jane@example.com",
    "address_1": "2667 boul des sources",
    "city": "Pointe-Claire",
    "province": "QC",
    "postcode": "H9S2H9",
    "country": "Canada",
    "packages": 1,
    "packagesDetail": [{
      "ref": "PKG-P-001",
      "weight": 1,
      "weight_unit": 2,
      "length": 30,
      "width": 20,
      "height": 10,
      "dimension_unit": 2
    }],
    "pickup_instruction": "Ring the side bell"
  }'
```

| 欄位 | 含義 |
|---|---|
| `type` | `D` 配送，或 `P` 取件 |
| `need_pick_up` | `0` — 貨物已入庫。`1` — 司機須上門取件 |
| `ref` | 外部參考號，用於查詢與對帳 |
| `name` / 地址 | 配送：收件人。取件：取件點 |
| `auto_deduplication` | 為 `1` 時，重複的包裹 `ref` 將被拒絕 |

**GraphQL：** `clientOrderCreate` ([GraphQL 手冊](/api/graphql/documentation#/client/clientOrderCreate))（JSON 標量）。

```json
{ "result": true, "id": 12345, "tracking_number": "SR123456789012", "orders_status_id": 2 }
```

`orders_status_id` `2` 是新訂單。請儲存 `id`、`tracking_number` 与 `ref`。

**驗證：** 使用相同的 `Idempotency-Key` 再次提交同一請求體，必須回傳相同的 `id`，不得產生第二筆訂單。

以下情況**不會**建立訂單：

| `code` | 處理方式 |
|---|---|
| `INSUFFICIENT_BALANCE` | `insufficient_balance` 中包含應付金額、可用餘額與差額。完成儲值後重試。 |
| `OUT_OF_DELIVERY_AREA` | 地址超出服務範圍，且商戶選擇刪除此類訂單。請改用服務範圍內的地址。 |
| `IDEMPOTENCY_CONFLICT` | 相同的冪等鍵對應了不同的請求體。請更換新的 `Idempotency-Key`。 |

被保留的超區訂單仍可能回傳 `result: true`，但 `shipping_price` 為 `null` 並帶 `warning`。須讀取該欄位。

## 4. 查詢訂單

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

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

**REST：** `GET /api/v1/orders/list` — 該帳號的全部訂單，最新的在前，每筆訂單都帶上它的包裹與貨物明細。同時傳 `page` 與 `per_page` 即可分頁（`per_page` 最大 1000）；不傳則回傳最新的 1000 筆並附上 `truncated` 標記。[REST 手冊](/api/documentation#/paths/v1-orders-list/get)

**GraphQL：** `ordersList` ([GraphQL 手冊](/api/graphql/documentation#/orders/ordersList))

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

**GraphQL：** `orders` ([GraphQL 手冊](/api/graphql/documentation#/orders/orders))

**驗證：** 確認該訂單歸屬目前帳號。`ref` 與建立時提交的值一致。`tracking_number` 与第 3 步一致。

## 5. 列印本地面單

**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
  }'
```

`type`：`TRACKING_NUMBER`、`ORDER_ID` 或 `REF`。`base64: 0`（預設）直接下發 PDF 流。 `base64: 1` 時整個回應主體是一個頂層 JSON 字串，直接存放 base64 PDF，而不是帶 `pdf_data` 欄位的物件。若希望標籤包在一般 JSON 物件中，請改呼叫 `POST /api/v2/shipping/getShippingLabel`。

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

**驗證：** PDF 可正常開啟。配送面單顯示收件人；取件面單顯示取件地址。若隱藏了某一側地址，該側將留空。

## 6. 追蹤

公開查詢介面，無需存取權杖。

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

[REST 手冊](/api/documentation#/operations/getPublicTracking) · [GraphQL 手冊](/api/graphql/documentation#/tracking/trackingPublic)

若 `ref` 已儲存為外部單號，同一 URL 亦可使用 `ref` 查詢。

**GraphQL**（帶類型，需要選擇集）：

```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 }
  }
}
```

請按 `tracking_event_status_id` 分支，不要依據 `description`（該字串跟隨 `Accept-Language`）：

| `tracking_event_status_id` | 側 | 含義 |
|---|---|---|
| `100` | 兩側 | 已接收訂單 |
| `300` / `301` | 配送 | 已入設施 |
| `450` | 配送 | 正在派送 |
| `500` | 配送 | 已送達 |
| `501` | 配送 | 派送失敗，需要重新計劃 |
| `460` | 取件 | 正在取件 |
| `510` | 取件 | 已取件 |
| `512` | 取件 | 取件失敗，稍後再試 |
| `513` | 取件 | 取件異常 |

`data` 最新在前。第一行表示目前狀態。`500` 之後 `deliveried: true`。

`500` 或 `510` 時 `proofs[]` 可能帶 `type` `1`（簽名）或 `2`（照片），以及 `file_id`、`signed_url`。該事件之後上傳的照片不會出現在該次回應中，須在第 7 步訂閱 `pod.files_updated`。

**驗證：** 建立完成後，最新事件應為 `100`，`deliveried` 為 false。未知運單號回傳 `result: false` 或 HTTP 404。應展示未找到狀態，請勿產生不存在的軌跡事件。

## 7. 設定事件通知

依業務需要設定以下回呼位址：

| 設定項 | 事件 | 觸發時機 |
|---|---|---|
| `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` | 你發起的取消被拒絕 |

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

**GraphQL:** `webhookSettingsUpdate` ([GraphQL 手冊](/api/graphql/documentation#/webhooks/webhookSettingsUpdate)).

```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.example.com/hooks/superroute",
    "order_status_change_webhook_url": "https://erp.example.com/hooks/superroute",
    "tracking_event_webhook_url": "https://erp.example.com/hooks/superroute",
    "webhook_sign_secret": "YOUR_LONG_RANDOM_SECRET",
    "webhook_verify_ssl": 1
  }'
```

用**原始報文**校驗 **v2**：`HMAC_SHA256(timestamp + "." + raw_body, secret)` 對照 `X-Webhook-Signature-V2`。用 `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` 的重複投遞不得被處理兩次。

## 8. 取消測試訂單

**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"}'
```

已經取消：`200` 且 `already_cancelled: true`。**GraphQL：** `ordersCancel` ([GraphQL 手冊](/api/graphql/documentation#/orders/ordersCancel))。

**驗證：** 公開追蹤不再將該運單視為有效在途件。若取消被拒（`409`，例如 `ORDER_ALREADY_IN_DELIVERY`），會發出 `order.cancel_failed`。

## 9. 批量建立（可選）

- `POST /api/v1/client/batchOrderCreate` — [REST 手冊](/api/documentation#/paths/v1-client-batchOrderCreate/post) — 同步等待全部列處理完成。
- `POST /api/v1/client/batchOrderCreateAsync` — [REST 手冊](/api/documentation#/paths/v1-client-batchOrderCreateAsync/post) — 立即回傳任務識別碼；輪詢 `GET /api/v1/client/async/{id}` — [REST 手冊](/api/documentation#/paths/v1-client-async-id/get) 或收 `order.create_async`。

欄位與第 3 步一致，以訂單陣列形式提交。每一行可以是 `type` `D` 或 `P`。**GraphQL：** `clientBatchOrderCreate` ([GraphQL 手冊](/api/graphql/documentation#/client/clientBatchOrderCreate)) / `clientBatchOrderCreateAsync` ([GraphQL 手冊](/api/graphql/documentation#/client/clientBatchOrderCreateAsync))。

**驗證：** 每一行均包含獨立的 `result`。訂單數量超過約 100 筆時，建議使用非同步介面。

## 驗收清單

請使用測試參考號，例如 `DEV-LOCAL-001` / `DEV-PICKUP-001`：

- [ ] （可選）`type` `D` 詢價應回傳價格。
- [ ] （可選）`type` `P` 詢價應回傳價格。
- [ ] 建立配送訂單回傳 `id` + `tracking_number`；同一 `Idempotency-Key` 不會重複建立訂單。
- [ ] 建立取件訂單回傳 `id` + `tracking_number`；`need_pick_up` 為 `1`。
- [ ] 列表與詳情中可查詢到目前帳號下的該訂單。
- [ ] 本地面單 PDF 可正常開啟，並顯示收件人或取件地址。
- [ ] 公開追蹤無需存取權杖即可回傳時間線，最新事件為 `100`。
- [ ] 應收到 `order.created`，且 v2 簽名校驗通過。
- [ ] 取消應回傳 `result: true`（重試時為 `already_cancelled: true`）。
