# 运输服务

运输服务 API 让客户账号预订其物流服务商已配置并分配给它的运输服务。客户自己的系统列出可使用的服务、加载某项服务的规则、对货件估价、创建运输订单、从账户余额中支付，并跟踪货件直至送达。本专题面向将进口商、商家或批发商的系统与为其服务的物流服务商对接的开发人员。

## 1. 可以构建的应用

本专题中的所有示例都使用同一个场景。**Harbourline Imports Inc.** 是多伦多的一家茶叶进口商，在其物流服务商处持有客户账号。该服务商从其 Toronto Hub 仓库（仓库 id `7`）提供服务 `intl_express`（International Express）。Harbourline 将两箱茶叶样品送到 Toronto Hub，寄给西雅图的一家经销商，采购订单号为 `HLI-PO-1058`。

- **从采购订单系统预订。** 采购订单下达后，Harbourline 的系统按 `intl_express` 对货件估价，以采购订单号作为参考号创建运输订单，并从预付账户余额中支付，无需任何人打开服务商的门户。
- **确认前的价格核查。** Harbourline 的采购人员在货件预订前即可看到两箱货物的运费、附加费、税费和总额；服务无法定价的货件会在订单创建前被拦下。
- **ERP 内的货件状态。** 每箱货物的运单号与采购订单关联保存；Webhook 将订单状态和追踪时间线同步到 ERP，夜间任务再与订单列表对账。
- **受控的变更。** 未付款的预订可直接更正；不再需要的预订可以取消，已付金额退回账户额度。

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

当调用方是物流商户的**客户**，并预订该商户自有的运输服务时，使用本类接口：商户设定价格方案、仓库、附加费和包装，并将服务分配给客户。客户只能看到并预订分配给它的服务。

以下情况请使用其他类接口：

- 调用方为物流商户本身（商户/客户账号），使用自有车队预订当日或本地的取件与配送：请阅读 **自有车队取件与配送**。
- 调用方按账号的协议价购买承运商面单（例如 UPS 或 FedEx）：请阅读 **承运商打单**。
- 客户将货物存放在服务商的仓库，并从库存出库发货：请阅读 **仓储与出库**。

**Uniorder：所有货件统一一个接口**（`/api/v1/uniorder/...`）是本地配送和承运商面单新集成的推荐统一入口。Uniorder 不涵盖运输服务：运输服务订单只能通过本专题介绍的 `/api/v1/customer/shipping-orders/...` 接口创建和管理。

## 3. 开始之前

- **账号类型。** 物流商户的**客户**账号，并由商户开通 **API 权限**。商户/客户账号或员工账号无法通过下面的客户登录接口登录。
- **服务分配。** 商户必须至少为客户分配一项有效的运输服务。未分配任何服务的客户将收到空的服务列表。
- **测试数据。** 与商户商定一个测试服务代码、一个测试仓库，并在测试账号中预存少量余额。使用 `HLI-PO-1058` 或 `DEV-SHIP-001` 等参考号，以便查找和取消测试订单。
- **令牌处理。** 仅从您的服务器调用 API。不要将密码和访问令牌放在浏览器或移动客户端中。令牌在登录一周后过期（`expires_at`）；请在过期前重新登录。
- **占位符。** 将 `YOUR_HOST` 替换为物流商户的主机名，将 `ACCESS_TOKEN` 替换为登录步骤返回的令牌。
- **JSON 错误。** 每个请求都发送 `Accept: application/json`，使校验错误以 JSON 返回，而不是重定向。

## 4. 以客户身份登录

登录用客户的邮箱和密码换取 Bearer 令牌。本专题之后的每个调用都发送该令牌。

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

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

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi...",
  "token_type": "Bearer",
  "expires_at": "2026-10-05 09:15:00",
  "expires_timestamp": 1791206100,
  "name": "Harbourline Imports Inc."
}
```

- `access_token`：在每个请求中以 `Authorization: Bearer ACCESS_TOKEN` 发送。GraphQL 请求提交至 `POST /api/graphql`，并使用相同的请求头。
- `expires_at` / `expires_timestamp`：请在此时间之前安排重新登录。

**验证：** 响应带有 `result: true` 和 `access_token`。未携带令牌的请求返回 `401`；使用非客户账号或未开通 API 权限的账号登录，同样返回 `401`。

## 5. 列出分配给客户的服务

服务列表告诉集成方可以预订哪些服务代码，以及每项服务接受仓库交货、上门取件还是两者皆可。请保存 `service_code`；之后的每个服务调用都使用它。

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

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/services \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "services": [
      {
        "id": 12,
        "service_code": "intl_express",
        "name": { "en": "International Express" },
        "offer_pickup": true,
        "allow_warehouse_delivery": true,
        "support_multi_package": true,
        "allow_special_requirements": false,
        "allow_purchase_supplies": true,
        "send_confirmation_email": true,
        "warehouses": [{ "id": 7, "name": "Toronto Hub" }]
      }
    ]
  }
}
```

- `service_code`：之后每个服务调用的路径参数。
- `offer_pickup` / `allow_warehouse_delivery`：`origin_type` 的允许取值（`pickup` / `warehouse`）。
- `support_multi_package`：一张订单是否可以包含多个包裹行。
- `services` 数组为空表示未为该客户分配任何服务。

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

```graphql
query CustomerShippingOrderServices {
  customerShippingOrderServices
}
```

**验证：** 列表至少包含一项服务，且您已保存其 `service_code`（本专题中为 `intl_express`）。

## 6. 加载服务配置

配置返回某项服务的下单表单所需的全部内容：接受交货的仓库、可选附加费、包装与耗材目录、单位，以及该服务可取件和可配送的国家。请在估价或创建任何内容之前，先据此校验您的订单数据。

**REST：** `GET /api/v1/customer/shipping-orders/services/{serviceCode}/config` — [REST 手册](/api/documentation#/paths/v1-customer-shipping-orders-services-serviceCode--config/get)

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/config \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "service": {
      "service_code": "intl_express",
      "offer_pickup": true,
      "allow_warehouse_delivery": true,
      "allow_special_requirements": false,
      "allow_purchase_supplies": true,
      "weight_mode": 2,
      "volumetric_factor": 5000
    },
    "warehouses": [
      {
        "id": 7,
        "name": "Toronto Hub",
        "address_1": "250 Dundas St W",
        "city": "Toronto",
        "province": "ON",
        "country": "CA",
        "postcode": "M5T 2Z5",
        "telephone": "4165550100"
      }
    ],
    "surcharges": [],
    "has_surcharges": false,
    "packagings": [],
    "products": [],
    "weight_units": { "2": { "name": "weight_kg", "accuracy": 3 } },
    "dimension_units": { "2": { "name": "dimension_cm", "accuracy": 1 } },
    "delivery_allowed_countries": ["CA", "US"],
    "pickup_allowed_countries": ["CA"]
  }
}
```

- `warehouses[].id`：`origin_type` 为 `warehouse` 时发送的 `warehouse_id`。不在此列表中的 id 会在创建时被拒绝。
- `service.weight_mode`：价格方案所需的包裹字段：`0` 实际重量（重量），`1` 体积重量（长、宽、高），`2` 计费重量（两者）。`null` 表示该服务由人工定价。同时发送重量和全部三个尺寸即可满足所有模式。
- `delivery_allowed_countries` / `pickup_allowed_countries`：在调用估价之前，拒绝不在这些列表中的目的地国家或取件国家。
- `surcharges[].id`、`packagings[].id`、`products[].id`：用于可选附加费、包装和耗材购买的 id。
- `weight_units` / `dimension_units`：包裹单位以数字发送。请像本专题所有示例一样发送 `weight_unit: 2`（千克）和 `dimension_unit: 2`（厘米）；省略这些字段时，两者也是默认值。

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

```graphql
query CustomerShippingOrderServiceConfig {
  customerShippingOrderServiceConfig(serviceCode: "intl_express")
}
```

**验证：** `result` 为 `true`；对于仓库交货，`warehouses` 包含您打算使用的仓库。`403` 表示该服务未分配给此客户；`404` 表示服务代码不存在或未启用。

## 7. 估价

估价按服务的价格方案为货件定价，不写入任何内容。请向采购人员显示总额；估价报告拒绝时，不要创建订单。

**REST：** `POST /api/v1/customer/shipping-orders/services/{serviceCode}/estimate-price` — [REST 手册](/api/documentation#/paths/v1-customer-shipping-orders-services-serviceCode--estimate-price/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/estimate-price \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "delivery_postcode": "98104",
    "delivery_country": "US",
    "packages": [{
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "needs_manual_quote": false,
    "shipping_fee": 118.40,
    "shipping_fee_total": 131.20,
    "fuel_surcharge": 12.80,
    "pickup_fee": null,
    "surcharges_total": 0,
    "sub_total": 131.20,
    "tax": { "total_tax": 17.06 },
    "total": 148.26,
    "currency": "CAD",
    "all_fees_calculated": true,
    "has_items_needing_quote": false,
    "unpriced_items": [],
    "refused": false,
    "refusal_message": null
  }
}
```

- `origin_type`：`warehouse`（客户将货物送到仓库；发送 `warehouse_id`）或 `pickup`（由服务商上门取件；发送 `pickup_postcode` 和 `pickup_country`）。只使用第 5 步允许的值。
- `packages`：每组相同包裹一行；`quantity` 为该行的倍数。
- `total` 和 `currency`：要显示的金额。只要有任何费用尚未计算，`total` 即为 `null`。
- `needs_manual_quote` / `has_items_needing_quote`：由商户人工为订单定价；订单可以创建，并在商户设定价格后支付。
- `refused` / `refusal_message`：服务拒绝其无法定价的货件。请不要创建订单，而是显示 `refusal_message`。
- 可选输入：`surcharges`、`products`（产品 id 到数量的映射，仅在 `allow_purchase_supplies` 为 true 时生效）、`has_special_requirements`、`coupon_code`。

**验证：** `result` 为 `true`，`refused` 为 `false`，且 `total` 有值或 `needs_manual_quote` 为 `true`。

## 8. 创建运输订单

创建调用在该服务上预订货件。集成方将返回的 `id` 与自己的采购订单关联保存；之后的每个调用都使用该 id。

**REST：** `POST /api/v1/customer/shipping-orders/services/{serviceCode}/orders` — [REST 手册](/api/documentation#/paths/v1-customer-shipping-orders-services-serviceCode--orders/post)

发送由您自己的固定 id（此处为采购订单号）生成的 `Idempotency-Key`。使用相同的键和相同的请求体重试时，返回第一次的响应，带有 `"replayed": true` 和请求头 `Idempotency-Replayed: true`，且不会创建第二张订单。

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: hli-po-1058" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "reference": "HLI-PO-1058",
    "delivery_name": "Cascade Tea Distributors",
    "delivery_telephone": "2065550143",
    "delivery_email": "receiving@cascadetea.example",
    "delivery_address_1": "300 5th Ave S",
    "delivery_city": "Seattle",
    "delivery_province": "WA",
    "delivery_country": "US",
    "delivery_postcode": "98104",
    "note": "Two cartons of sample tea, dock door B",
    "package": [{
      "description": "Sample tea carton",
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "value": 380,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 0,
    "total_price": 148.26,
    "price_breakdown": { "total": 148.26, "currency": "CAD" },
    "promotion_id": null,
    "tracking_number": null
  }
}
```

- 创建时包裹的请求体键为 `package`（估价时为 `packages`）。`quantity` 为 N 的每一行生成 N 个包裹，每个包裹都有自己的运单号。
- 必填字段：`delivery_name`、`delivery_telephone`、`delivery_address_1`、`delivery_city`、`delivery_province`、`delivery_country`、`delivery_postcode`、`origin_type`、`package[].weight`；`warehouse` 时另需 `warehouse_id`，`pickup` 时另需 `pickup_name`、`pickup_telephone`、`pickup_address_1`、`pickup_city`、`pickup_province`、`pickup_country`、`pickup_postcode`。
- 可选字段：`reference`（保存为订单的 `reference_number`）、`delivery_email`、`delivery_address_2`、`scheduled_date`、`time_window`、`note`、`special_requirements`（文本行数组，仅在服务允许时生效）、`products`、`surcharges`、`coupon_code`。
- `id`：请保存。`status` `0` 为待处理（等待付款）。
- `total_price`：第 9 步扣收的金额。订单等待人工报价期间为 `0`。
- 订单级的 `tracking_number` 为 `null`；运单号位于各包裹上，在第 10 步读取。
- 新建订单时，接口返回 HTTP `201`。

**验证：** 响应带有 `result: true` 和 `id`。使用相同的 `Idempotency-Key` 重复同一请求，返回相同的 `id` 并带有 `"replayed": true`。

## 9. 从账户余额支付订单

运输订单从客户的账户余额中全额支付。已付款的订单从待处理变为已确认，服务商开始处理。

先读取金额：

**REST：** `GET /api/v1/customer/shipping-orders/{id}/payment-info` — [REST 手册](/api/documentation#/paths/v1-customer-shipping-orders-id--payment-info/get)

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/9001/payment-info \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "order_id": 9001,
    "currency": "CAD",
    "total_price": "148.26",
    "paid_amount": "0.00",
    "remaining_balance": "148.26",
    "user_balance": "500.00",
    "has_sufficient_balance": true,
    "shortfall": 0,
    "payment_options": [
      { "type": "remaining_balance", "amount": 148.26 }
    ]
  }
}
```

然后支付：

**REST：** `POST /api/v1/customer/shipping-orders/{id}/pay` — [REST 手册](/api/documentation#/paths/v1-customer-shipping-orders-id--pay/post)

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipping-orders/9001/pay \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"payment_type":"remaining_balance"}'
```

```json
{
  "result": true,
  "message": "Payment of $148.26 processed successfully.",
  "data": {
    "order_id": 9001,
    "status": 1,
    "amount_paid": "148.26"
  }
}
```

- `has_sufficient_balance` / `shortfall`：余额不足以支付 `remaining_balance` 时，请先为账户充值再支付。
- `payment_type`：仅支持 `remaining_balance`；始终扣收全部剩余金额。
- `data.status` `1` 为已确认。

**验证：** 支付调用返回 `result: true` 和 `status` `1`，再次调用 `payment-info` 返回 `400`，因为订单已全额支付。余额不足时，支付调用返回 `422`，且不扣收任何费用。

## 10. 查询订单并追踪包裹

详情调用返回当前状态和每个包裹的运单号。请将包裹运单号与采购订单关联保存；公开追踪接受其中每一个。

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

```bash
curl https://YOUR_HOST/api/v1/customer/shipping-orders/9001 \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 1,
    "status_name": "Confirmed",
    "can_edit": false,
    "can_cancel": true,
    "shipping_code": "K7RW2Q",
    "tracking_number": null,
    "reference_number": "HLI-PO-1058",
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "delivery_address": { "name": "Cascade Tea Distributors", "city": "Seattle", "country": "US" },
    "package_count": 2,
    "packages": [
      { "id": 55101, "description": "Sample tea carton", "tracking_number": "SR123456789012", "weight": 12, "weight_unit": 2 },
      { "id": 55102, "description": "Sample tea carton", "tracking_number": "SR123456789013", "weight": 12, "weight_unit": 2 }
    ],
    "total_price": 148.26
  }
}
```

- `status`：`0` 待处理，`1` 已确认，`2` 运输中，`3` 已发货，`4` 已取消，`5` 失败，`6` 部分已取件，`7` 已取件，`8` 处理中。
- `can_edit` / `can_cancel`：当前是否允许执行第 12 步。
- `packages[].tracking_number`：需要保存和追踪的单号。
- `shipping_code`：仓库交货界面接受的代码；请打印在交货单据上。

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

```graphql
query CustomerShippingOrderShow {
  customerShippingOrderShow(id: 9001) {
    result
    message
    data {
      id
      status
      status_name
      can_cancel
      reference_number
      packages {
        id
        tracking_number
        weight
      }
      total_price
    }
  }
}
```

如需对某项服务的所有订单对账（例如在夜间任务中），可按筛选条件列出订单。`id` 筛选条件匹配订单 id、运单号或参考号。

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

```bash
curl "https://YOUR_HOST/api/v1/customer/shipping-orders/services/intl_express/orders?id=HLI-PO-1058&created_at_from=2026-09-01&per_page=20" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Accept: application/json"
```

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

公开追踪不需要令牌，返回单个包裹的事件时间线：

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

```bash
curl https://YOUR_HOST/api/v1/tracking/SR123456789012 \
  -H "Accept: application/json"
```

```json
{
  "result": true,
  "deliveried": false,
  "data": [
    {
      "tracking_event_status_id": 1,
      "otep_status": "received",
      "description": "Received at warehouse",
      "location_city": "Toronto",
      "updated_at": "2026-09-29 10:42:00"
    }
  ]
}
```

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

```graphql
query TrackingPublic {
  trackingPublic(trackingNumber: "SR123456789012") {
    result
    message
    deliveried
    data {
      tracking_event_status_id
      otep_status
      description
      location_city
      updated_at
    }
  }
}
```

**验证：** 详情返回本客户的订单，每个包裹一个运单号；对包裹运单号调用公开追踪返回 `result: true`。其他客户的订单 id 返回 `404`。

## 11. 接收 Webhook

Webhook 将订单创建、状态变更和追踪事件发送到您的服务器，集成方无需轮询。客户账号自行配置其 Webhook URL 和签名密钥。

创建运输订单时，服务商还会为其调度团队创建一张关联的取件订单。Webhook 针对该关联订单发送：其 `ref` 为 `Shipping-Pickup-{shipping order id}`（例如 `Shipping-Pickup-9001`），其每个包裹都在 `external_tracking_number` 中带有运输包裹的运单号。请按这两个字段匹配收到的事件。

| 设置项 | 事件 | 集成方的处理 |
|---|---|---|
| `order_create_webhook_url` | `order.created` | 通过 `ref` 和 `packages[].external_tracking_number` 将事件与运输订单关联 |
| `tracking_event_webhook_url` | `tracking.event` | 将事件追加到包裹时间线 |
| `order_status_change_webhook_url` | `order.status_change` | 更新您系统中显示的状态 |

**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" \
  -H "Accept: application/json" \
  -d '{
    "order_create_webhook_url": "https://erp.harbourline-imports.example/hooks/order-created",
    "tracking_event_webhook_url": "https://erp.harbourline-imports.example/hooks/tracking",
    "order_status_change_webhook_url": "https://erp.harbourline-imports.example/hooks/status",
    "webhook_sign_secret": "hli-webhook-secret-7f2c9a1e5b"
  }'
```

```json
{
  "result": true,
  "changed_keys": [
    "order_create_webhook_url",
    "tracking_event_webhook_url",
    "order_status_change_webhook_url",
    "webhook_sign_secret"
  ],
  "recipient_type": "customer",
  "settings": {
    "webhook_sign_secret": "*************************1e5b",
    "tracking_event_webhook_url": "https://erp.harbourline-imports.example/hooks/tracking"
  }
}
```

- 只更改提交的键；空字符串会清除 URL。`webhook_sign_secret` 须为 16 至 255 个字符，密钥为空时不会发送任何 Webhook。
- 对客户账号而言，`recipient_type` 为 `customer`。

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

```graphql
mutation WebhookSettingsUpdate {
  webhookSettingsUpdate(
    order_status_change_webhook_url: "https://erp.harbourline-imports.example/hooks/status"
    tracking_event_webhook_url: "https://erp.harbourline-imports.example/hooks/tracking"
  )
}
```

对原始请求体校验 **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;
}
```

**验证：** 完成设置调用后，一次测试创建会产生 `order.created` 事件，其 `ref` 为对应新运输订单 id 的 `Shipping-Pickup-{id}`，且签名校验通过。

## 12. 修改或取消订单

订单在待处理状态（付款前）可以更正，在待处理或已确认状态可以取消。取消已付款的订单时，已付金额退回账户额度。

如需修改，请使用与第 8 步相同的字段重新发送完整订单。价格将重新计算。

**REST：** `PUT /api/v1/customer/shipping-orders/{id}` — [REST 手册](/api/documentation#/paths/v1-customer-shipping-orders-id/put)

```bash
curl -X PUT https://YOUR_HOST/api/v1/customer/shipping-orders/9001 \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "origin_type": "warehouse",
    "warehouse_id": 7,
    "reference": "HLI-PO-1058",
    "delivery_name": "Cascade Tea Distributors",
    "delivery_telephone": "2065550143",
    "delivery_email": "receiving@cascadetea.example",
    "delivery_address_1": "300 5th Ave S",
    "delivery_address_2": "Suite 210",
    "delivery_city": "Seattle",
    "delivery_province": "WA",
    "delivery_country": "US",
    "delivery_postcode": "98104",
    "package": [{
      "description": "Sample tea carton",
      "weight": 12,
      "length": 50,
      "width": 40,
      "height": 35,
      "weight_unit": 2,
      "dimension_unit": 2,
      "value": 380,
      "quantity": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 0,
    "total_price": 148.26,
    "promotion_id": null,
    "coupon_code": null,
    "promotion_discount": null
  }
}
```

如需取消：

**REST：** `POST /api/v1/customer/shipping-orders/{id}/cancel` — [REST 手册](/api/documentation#/paths/v1-customer-shipping-orders-id--cancel/post)

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

```json
{
  "result": true,
  "data": {
    "id": 9001,
    "status": 4,
    "refund_amount": 148.26
  },
  "message": "Order cancelled. $148.26 refunded to your credit."
}
```

- `status` `4` 为已取消。关联的取件订单会被删除。
- `refund_amount`：退回账户额度的金额；未付款订单为 `0`。
- 在向用户提供这些操作之前，请先读取第 10 步的 `can_edit` 和 `can_cancel`。

**验证：** 取消返回 `status` `4`，详情显示 `status_name` `Cancelled`。再次取消，或取消处于运输中及之后状态的订单，返回 `403` 及消息 `This order can no longer be cancelled.`；修改已付款的订单返回 `403`。

## 13. 错误处理

| 情形 | HTTP 状态 | 代码 | 集成方的处理 |
|---|---|---|---|
| 令牌缺失、过期或无效；使用非客户账号或未开通 API 权限的账号登录 | `401` | — | 重新登录；若登录本身失败，请商户检查账号类型和 API 权限 |
| 该服务未分配给此客户 | `403` | — | 重新读取服务列表（第 5 步），只预订已分配的服务 |
| 从平台应用会话调用 API，而该应用已禁用运输订单 | `403` | `APP_CAPABILITY_DISABLED` | 请商户为该应用启用运输订单 |
| 服务代码未知或未启用；此客户下找不到该订单 id | `404` | — | 刷新服务列表；检查已保存的订单 id |
| 必填字段缺失或无效 | `422` | — | 读取响应体中的 `errors`，更正字段后重新发送 |
| 服务不提供该起运类型，或仓库不在服务的列表中 | `422` | — | 使用第 5 步和第 6 步中的 `origin_type` 和 `warehouse_id` |
| 服务无法为货件定价，且拒绝未定价的货件 | `422` | `unpriced_refused` | 未创建任何内容；显示 `message`，不要原样重试 |
| 订购的耗材缺货 | `422` | — | 读取 `stock_shortages`，减少数量后重新发送 |
| 相同的 `Idempotency-Key` 用于不同的请求体 | `409` | `IDEMPOTENCY_CONFLICT` | 新订单使用新的键；切勿将同一个键用于不同内容 |
| 使用该键的第一次请求仍在处理中时重试 | `409` | `IDEMPOTENCY_IN_PROGRESS` | 等待 `Retry-After` 秒后，使用相同的键和请求体重试 |
| 余额不足时支付 | `422` | — | 为账户充值后再次支付 |
| 对已全额支付的订单查询支付信息或支付 | `400` | — | 将订单视为已支付；读取详情 |
| 订单已离开待处理或已确认状态后取消 | `403` | — | 显示该订单已无法取消；联系商户 |
| 付款后修改 | `403` | — | 取消后创建新订单，或联系商户 |
| 估价、创建、支付或取消期间出现服务器错误 | `500` | — | 重试一次；创建时使用相同的 `Idempotency-Key` 重试 |

## 验收清单

请使用 `DEV-SHIP-001` 或 `HLI-PO-1058` 等测试参考号：

- [ ] 客户登录返回 `access_token`；未携带令牌的请求返回 `401`。
- [ ] 服务列表不为空，且您已保存一个 `service_code`。
- [ ] 配置返回该服务的仓库、单位和允许的国家，且您的表单使用了这些内容。
- [ ] 估价返回 `total`（或 `needs_manual_quote: true`），被拒绝的货件不会被创建。
- [ ] 创建返回 `id`；相同的 `Idempotency-Key` 和相同的请求体返回相同的 `id` 并带有 `"replayed": true`。
- [ ] 支付成功且状态变为已确认，或您已确认余额不足时返回 `422` 且不扣收任何费用。
- [ ] 详情显示本客户的订单，每个包裹一个运单号，且公开追踪可以查到每个包裹。
- [ ] Webhook 已配置签名密钥；一次测试创建产生 `ref` 为 `Shipping-Pickup-{id}` 的 `order.created`，且签名校验通过。
- [ ] 取消测试订单返回 `status` `4` 和预期的 `refund_amount`；再次取消返回 `403`。
