# 仓储与出库

仓储与出库 API 让仓储商户的客户账号将货物入库存储、支付存储期费用，之后将在库包裹出库发给自己的买家。本专题面向将库存存放在第三方仓库（3PL）、需要从自有系统自动完成仓储预订、库存查看和出库发货的商家和平台。所有调用均以客户账号的身份执行，而不是以仓储商户的身份。

## 1. 可以构建的应用

本专题中的示例都遵循同一个场景。**Northwind Outdoor** 是一家季节性在线销售冬季装备的商家，于 2026 年 11 月 1 日至 2027 年 3 月 31 日将其冬季库存存放在其 3PL 的 **Toronto Hub**（仓库 `7`）。买家订购一箱保暖夹克时，Northwind 从库存中将该箱货物发往位于渥太华的买家。

- **季节性仓储预订。** 商家的后台在货物离开供应商之前，为每个入库箱询价并预订存储期，并从账户余额中支付存储费。
- **实时库存视图。** 商家的店铺或 ERP 列出仓库实际已收到且仍可发货的包裹，确保只将真实库存用于履约。
- **从库存履约订单。** 买家下单时，商家的系统为出库货件估价，为在库包裹创建出库请求并付款，然后为买家记录运单号。
- **状态跟进与更正。** 商家的系统读取每张仓储订单和出库单的状态，通过公开追踪跟踪货件，并在仍允许时取消不再需要的出库单。

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

当货物已经或将要存放在商户的仓库中，且货件从该库存发出时，使用本专题。流程为：登录 → 读取仓储配置 → 仓储询价 → 创建仓储订单 → 支付 → 列出在库包裹 → 列出服务并估算出库价格 → 创建出库单 → 支付 → 查询与追踪 → Webhook → 取消。

其他专题适用于其他情况：

- **Uniorder：所有货件统一一个接口** —— 预订本地配送或承运商面单的新集成推荐使用的统一入口（`/api/v1/uniorder/...`）。Uniorder **不**涵盖仓储与出库；仓储订单和出库单只能通过本专题中的客户接口创建。
- **运输服务** —— 客户使用商户的运输服务寄送不在仓储中的货物。
- **承运商打单** —— 商户直接为自己的包裹购买承运商面单。
- **自有车队取件与配送** —— 商户使用自有车队预订取件和配送。

## 3. 开始之前

- **账号类型。** 仓储商户的**客户**账号（运营仓库的商户为服务提供方）。商户（客户）账号的令牌不能用于 `/api/v1/customer/...` 接口。
- **权限。** 客户账号需要 API 访问权限。仓储接口还需要仓储功能；出库接口要求商户已为该客户启用出库（或合并发货），否则返回 `403`。
- **余额。** 仓储和出库费用从客户的账户余额中扣除。测试时，请商户为测试客户的余额充值。
- **测试数据。** 一个仓库 `id`；若不允许自定义包裹，至少一个包装 `id`；以及至少一项可从该仓库使用的有效运输服务。出库只有在仓库**已收到**在库包裹之后才能进行；测试时，请仓库人员对测试仓储订单执行收货。
- **令牌处理。** 从您的服务器登录，将令牌保存在服务器上，切勿将其放入浏览器或移动端代码中。
- **占位符。** 将 `YOUR_HOST` 替换为您的平台主机，将 `ACCESS_TOKEN` 替换为第 4 步获得的令牌。

## 4. 以客户身份登录

之后的每个调用都使用客户的 Bearer 令牌授权。您的集成登录一次，将令牌保存在服务器端，并在 `expires_at` 之前续期。

**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" \
  -d '{"email":"ops@northwind-outdoor.example","password":"your_password"}'
```

```json
{
  "result": true,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi...",
  "token_type": "Bearer",
  "expires_at": "2027-09-28 10:15:00",
  "expires_timestamp": 1822040100,
  "name": "Northwind Outdoor"
}
```

- `access_token` —— 在每个请求中按下方请求头发送。
- `expires_at` / `expires_timestamp` —— 在此时间之前重新登录。

```
Authorization: Bearer ACCESS_TOKEN
```

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

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

## 5. 读取仓储配置

配置包列出客户可使用的仓库、包装目录、单位和附加费。您的集成在每个会话中读取一次，用于选择仓库并构建有效的包裹行。

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

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

```json
{
  "result": true,
  "data": {
    "warehouses": [
      { "id": 7, "name": "Toronto Hub", "address": "10 Main St", "city": "Toronto", "province": "ON", "postcode": "M5V 2T6" }
    ],
    "packagings": [
      { "id": 1, "name": "Large Carton", "type": "Box", "length": 60, "width": 40, "height": 40, "dimension_unit": 2 }
    ],
    "dimension_units": { "1": { "name": "dimension_mm" }, "2": { "name": "dimension_cm" }, "3": { "name": "dimension_m" }, "4": { "name": "dimension_inch" } },
    "weight_units": { "1": { "name": "weight_g" }, "2": { "name": "weight_kg" }, "3": { "name": "weight_oz" }, "4": { "name": "weight_lb" } },
    "allow_custom_package": true,
    "surcharges": [],
    "form_bindings": []
  }
}
```

- `warehouses[].id` —— 之后每个调用中的 `warehouse_id`。
- `allow_custom_package` —— 为 `false` 时，每个仓储项都必须带有 `packagings[]` 中的 `packaging_id`；为 `true` 时，仓储项可以只用尺寸描述。
- `dimension_units` / `weight_units` —— 包裹行中使用的整数代码（`2` = 厘米，`2` = 千克）。
- `form_bindings` —— 商户要求在仓储订单上填写的表单；请在第 7 步以 `form_data` 发送其答案。

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

```graphql
query {
  customerStorageOrderConfig
}
```

**验证：** 您已记录一个仓库 `id`；若目录不为空，还记录了一个包装 `id`。

## 6. 存储期询价

询价在预订任何内容之前，为计划中的包裹计算存储期价格。您的集成显示或核对该价格，然后使用相同的输入创建订单。

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders/calculate-price \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": 7,
    "start_date": "2026-11-01",
    "end_date": "2027-03-31",
    "items": [{
      "qty": 2,
      "length": 60,
      "width": 40,
      "height": 40,
      "dimension_unit": 2,
      "weight": 14,
      "weight_unit": 2
    }]
  }'
```

```json
{
  "success": true,
  "price": {
    "total_price": "186.45",
    "currency": "CAD",
    "shipping_price": 186.45
  }
}
```

- `success` —— 已计算出价格时为 `true`。
- `price.total_price` / `price.currency` —— 该存储期的含税存储价格。
- `promotion` —— 仅在适用促销时出现。

**验证：** `success` 或 `result` 为 true，且您获得了价格。缺少 `warehouse_id` 或日期时返回 `400`。

## 7. 创建仓储订单

仓储订单向仓库预告入库包裹，并确定存储期。您的集成保存返回的 id；支付、查询和取消订单时都需要它。

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/storage-orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: northwind-winter-2026-po-4471" \
  -d '{
    "warehouse_id": 7,
    "start_date": "2026-11-01",
    "end_date": "2027-03-31",
    "notes": "Winter 2026 stock, PO 4471",
    "items": [{
      "description": "Insulated jackets, carton of 12",
      "qty": 2,
      "length": 60,
      "width": 40,
      "height": 40,
      "dimension_unit": 2,
      "weight": 14,
      "weight_unit": 2,
      "value": 1800
    }]
  }'
```

```json
{
  "result": true,
  "message": "Storage order created",
  "data": { "id": 1024, "status": "pending payment" }
}
```

- `data.id` —— 仓储订单 id。请与您的采购订单一起保存。
- `data.status` —— 订单付款前为 `pending payment`。
- `Idempotency-Key` —— 由您自己的固定 id 生成。以相同的请求体重复使用同一个键时，返回第一次的响应（`replayed: true`）；以不同的请求体使用同一个键时，将以 `409 IDEMPOTENCY_CONFLICT` 拒绝。
- 必填字段：`warehouse_id`、`start_date`、`end_date`（晚于 `start_date`），以及带有 `qty`、`length`、`width`、`height`、`dimension_unit` 的 `items[]`。`allow_custom_package` 为 `false` 时，还需添加 `items[].packaging_id`。

**验证：** 响应带有 `data.id`。请保存该仓储订单 id。

## 8. 支付仓储费用

付款即确认仓储订单。您的集成可以先读取应付金额，然后从客户余额中支付。

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

```bash
curl https://YOUR_HOST/api/v1/customer/storage-orders/1024/payment-info \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "success": true,
  "result": true,
  "data": {
    "order_id": 1024,
    "currency": "CAD",
    "total_price": "186.45",
    "paid_amount": "0.00",
    "remaining_balance": "186.45",
    "user_balance": "500.00",
    "has_sufficient_balance": true,
    "minimum_payment": "186.45"
  }
}
```

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

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

```json
{
  "success": true,
  "result": true,
  "message": "Payment of $186.45 processed successfully. Your storage order is now confirmed.",
  "new_balance": 313.55,
  "paid_amount": 186.45,
  "charge_amount": 186.45,
  "is_fully_paid": true
}
```

- `payment_type` —— `full_balance`（默认，支付剩余全部金额）、`minimum_payment`（支付商户要求的最低金额），或 `custom` 并配合 `custom_amount`。
- `is_fully_paid` —— 已无剩余应付金额时为 `true`。
- 余额不足时返回 `400` 并带有 `customer_balance`；请为余额充值后重试。

查询订单，以确认其状态，以及之后仓库已收到哪些包裹。

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

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

```json
{
  "result": true,
  "data": {
    "id": 1024,
    "status": "confirmed",
    "can_cancel": true,
    "store_from": "2026-11-01",
    "store_to": "2027-03-31",
    "warehouse_id": 7,
    "packages": [
      { "id": 5001, "description": "Insulated jackets, carton of 12", "quantity": 1, "received": false },
      { "id": 5002, "description": "Insulated jackets, carton of 12", "quantity": 1, "received": false }
    ],
    "total_price": 186.45,
    "currency": "CAD"
  }
}
```

- `status` —— 付款后为 `confirmed`；货物到达后依次变为 `partial received` / `storage in progress`。
- `packages[].received` —— 仓库收到该包裹后为 `true`。
- `can_cancel` —— 仓储订单是否仍可取消。

**GraphQL：** `customerStorageOrderShow`（[GraphQL 手册](/api/graphql/documentation#/customer/customerStorageOrderShow)）；所有仓储订单的列表为 `customerStorageOrders`（[GraphQL 手册](/api/graphql/documentation#/customer/customerStorageOrders)）。

```graphql
query {
  customerStorageOrderShow(id: 1024) {
    result
    data {
      id
      status
      can_cancel
      packages { id description received }
    }
  }
}
```

**验证：** 仓储订单已付款 / 已确认。返回 `400` 并带有 `customer_balance` 表示需要为余额充值后重试。

以下出库操作只有在包裹于仓库中**已收货**后才能进行。测试时，请等待仓库人员（或测试收货操作）将其标记为已收货，然后继续。

## 9. 列出仍在库的货物

此列表即您的集成可以发货的库存。它只包含仓库已收到、且尚未锁定到其他出库单的包裹。

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

```bash
curl "https://YOUR_HOST/api/v1/customer/shipout-orders/available-items?warehouse_id=7" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "warehouses": [{ "id": 7, "name": "Toronto Hub", "available_count": 2 }],
    "storage_orders": [{
      "id": 1024,
      "warehouse_id": 7,
      "packages": [
        { "id": 5001, "description": "Insulated jackets, carton of 12", "weight": 14, "weight_unit": 2, "length": 60, "width": 40, "height": 40, "dimension_unit": 2, "value": 1800 },
        { "id": 5002, "description": "Insulated jackets, carton of 12", "weight": 14, "weight_unit": 2, "length": 60, "width": 40, "height": 40, "dimension_unit": 2, "value": 1800 }
      ]
    }]
  }
}
```

- `storage_orders[].packages[].id` —— 第 10 步中要出库的 `storage_package_ids`。
- `warehouses[].available_count` —— 每个仓库的可用包裹数量。

**GraphQL：** `customerShipoutAvailableItems`（[GraphQL 手册](/api/graphql/documentation#/storage-shipout/customerShipoutAvailableItems)）

```graphql
query {
  customerShipoutAvailableItems(warehouse_id: 7) {
    result
    data {
      warehouses { id name available_count }
      storage_orders { id warehouse_id packages { id description weight length width height } }
    }
  }
}
```

**验证：** 您已记录一个或多个 `storage_package_ids`（例如 `5001`）。列表为空表示尚未收到任何货物——请勿创建出库单。`403` 表示该客户的出库功能已禁用。

## 10. 估价并创建出库单

出库单由商户的某项运输服务定价。您的集成列出可从该仓库使用的服务，为买家的目的地估价，然后为所选包裹创建出库单。

**REST：** `GET /api/v1/customer/shipout-orders/services?warehouse_id=7` — [REST 手册](/api/documentation#/paths/v1-customer-shipout-orders-services/get)

```bash
curl "https://YOUR_HOST/api/v1/customer/shipout-orders/services?warehouse_id=7" \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

```json
{
  "result": true,
  "data": {
    "services": [
      { "id": 5, "service_code": "intl_express", "name": { "en": "Express" }, "pricing_method": 1, "pricing_method_name": "Shipping Price Plan", "support_multi_package": true }
    ]
  }
}
```

记录一个 `service_code`。

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/services/intl_express/estimate \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "warehouse_id": 7,
    "delivery_postcode": "K2P1L4",
    "delivery_country": "CA",
    "packages": [{
      "weight": 14,
      "length": 60,
      "width": 40,
      "height": 40,
      "weight_unit": 2,
      "dimension_unit": 2
    }]
  }'
```

```json
{
  "result": true,
  "data": {
    "shipping_fee": 38.5,
    "fuel_surcharge": 4.2,
    "handling_fee": 0,
    "sub_total": 42.7,
    "tax": 5.55,
    "total": 48.25,
    "currency": "CAD",
    "has_items_needing_quote": false,
    "refused": false
  }
}
```

- `total` / `currency` —— 该目的地的估算价格。
- `has_items_needing_quote` —— 服务由人工定价时为 `true`；仓库在出库单创建后设定价格，付款须等待定价。
- `refused` / `refusal_message` —— 服务因无法定价而不接受此货件。

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/services/intl_express/orders \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: northwind-order-NW-20931" \
  -d '{
    "warehouse_id": 7,
    "storage_package_ids": [5001],
    "delivery_name": "Maya Chen",
    "delivery_telephone": "6135550142",
    "delivery_email": "maya.chen@example.com",
    "delivery_address_1": "150 Elgin St",
    "delivery_city": "Ottawa",
    "delivery_province": "ON",
    "delivery_country": "CA",
    "delivery_postcode": "K2P1L4",
    "note": "Web order NW-20931"
  }'
```

```json
{
  "result": true,
  "data": {
    "id": 8001,
    "status": 0,
    "is_storage_shipout": true,
    "total_price": "48.25",
    "price_breakdown": { "total": 48.25, "has_items_needing_quote": false },
    "has_items_needing_quote": false,
    "storage_package_ids": [5001]
  }
}
```

- `data.id` —— 出库单 id。请与买家的订单一起保存。
- `data.status` —— `0` = 待处理（等待付款），`1` = 已确认，`2` = 运输中，`3` = 已发货，`4` = 已取消，`5` = 失败。
- `storage_package_ids` —— 这些包裹现已锁定到本出库单，不再出现在第 9 步中。
- 必填字段：`warehouse_id`、`storage_package_ids`、`delivery_name`、`delivery_telephone`、`delivery_address_1`、`delivery_city`、`delivery_province`、`delivery_country`、`delivery_postcode`。所有包裹必须来自同一仓库。

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

```graphql
mutation {
  customerCreateShipoutOrder(
    service_code: "intl_express"
    warehouse_id: 7
    storage_package_ids: [5001]
    delivery_name: "Maya Chen"
    delivery_telephone: "6135550142"
    delivery_email: "maya.chen@example.com"
    delivery_address_1: "150 Elgin St"
    delivery_city: "Ottawa"
    delivery_province: "ON"
    delivery_country: "CA"
    delivery_postcode: "K2P1L4"
    note: "Web order NW-20931"
  ) {
    result
    message
    data { id status total_price has_items_needing_quote storage_package_ids }
  }
}
```

**验证：** 响应带有出库单 `id`。所选的仓储包裹已锁定到该请求。

## 11. 支付出库单

出库单付款后，仓库才会处理。您的集成从客户账户中支付剩余金额；省略 `amount` 即全额支付。

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

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

```json
{
  "result": true,
  "data": {
    "transaction_id": 9001,
    "amount": "48.25",
    "order_status": 1,
    "paid_amount": 48.25,
    "remaining_balance": 0
  }
}
```

- `amount`（请求参数，可选）—— 部分金额；默认为剩余全部金额。
- `order_status` —— 全额付款后为 `1`（已确认）。
- `remaining_balance` —— 全额付款后为 `0`。

**GraphQL：** `customerPayShipout`（[GraphQL 手册](/api/graphql/documentation#/storage-shipout/customerPayShipout)）

```graphql
mutation {
  customerPayShipout(id: 8001) {
    result
    message
    data { transaction_id amount order_status paid_amount remaining_balance }
  }
}
```

**验证：** 支付记录了金额（或返回带有明确原因的 `402` / `422`）。`402` 表示余额不足；`422` 表示订单尚不可支付（例如仍在等待人工报价）或金额无效。

## 12. 查询和追踪出库单

您的集成查询出库单以跟进其状态，仓库发货后再通过运单号跟踪货件。

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

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

```json
{
  "result": true,
  "data": {
    "id": 8001,
    "status": 3,
    "status_label": "Shipped",
    "warehouse": { "id": 7, "name": "Toronto Hub" },
    "shipping_service": { "id": 5, "service_code": "intl_express" },
    "total_price": "48.25",
    "paid_amount": 48.25,
    "remaining_balance": 0,
    "can_be_paid": false,
    "can_be_cancelled": false,
    "storage_packages": [{ "id": 5001, "storage_order_id": 1024, "description": "Insulated jackets, carton of 12" }]
  }
}
```

- `status` / `status_label` —— 出库单的当前状态。
- `can_be_paid` / `can_be_cancelled` —— 当前是否允许执行第 11 步或第 14 步。

已有运单号时：

**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,
  "deliveried": false,
  "data": [
    { "tracking_event_status_id": 3, "description": "Package picked up", "location_city": "Toronto", "updated_at": "2026-12-02 14:30:00" }
  ]
}
```

- `data[]` —— 按时间顺序排列的追踪事件。
- `deliveried` —— 货件送达后为 `true`。

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

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

**验证：** 查询出库单返回预期的 `status`。已有单号后，公开追踪可以查到该货件。

## 13. 订阅 Webhook

Webhook 将追踪和状态变更推送到您的服务器，无需轮询。客户账号自行设置其 Webhook URL 和签名密钥；这些设置保存在客户账号上，而不是商户上。

**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 '{
    "tracking_event_webhook_url": "https://hooks.northwind-outdoor.example/tracking",
    "order_status_change_webhook_url": "https://hooks.northwind-outdoor.example/status",
    "webhook_sign_secret": "nw-webhook-secret-2026-0123456789"
  }'
```

```json
{
  "result": true,
  "changed_keys": ["tracking_event_webhook_url", "order_status_change_webhook_url", "webhook_sign_secret"],
  "recipient_type": "customer",
  "settings": {
    "webhook_sign_secret": "************6789",
    "tracking_event_webhook_url": "https://hooks.northwind-outdoor.example/tracking",
    "order_status_change_webhook_url": "https://hooks.northwind-outdoor.example/status"
  }
}
```

- 只更改提交的键；未知的键或无效的 URL 返回 `400`。
- `recipient_type` —— `customer` 表示这些设置属于客户账号。
- `webhook_sign_secret` —— 16 至 255 个字符；请保存在您的服务器上用于校验签名。

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

校验 **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;
}
```

**验证：** 更新调用返回包含所提交键的 `changed_keys`，且您的 URL 收到的测试事件通过上述签名校验。

## 14. 取消出库单或仓储订单

取消会释放已预留的内容。取消出库单会将其包裹退回库存；取消仓储订单会终止货物尚未收到的预订。

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

```bash
curl -X POST https://YOUR_HOST/api/v1/customer/shipout-orders/8001/cancel \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Buyer cancelled web order NW-20931"}'
```

```json
{ "result": true, "message": "Shipout cancelled." }
```

- `reason`（可选）—— 随取消一并记录。
- 出库单只有在待处理（`0`）或已确认（`1`）状态时才能取消。

**GraphQL：** `customerCancelShipout`（[GraphQL 手册](/api/graphql/documentation#/storage-shipout/customerCancelShipout)）

```graphql
mutation {
  customerCancelShipout(id: 8001, reason: "Buyer cancelled web order NW-20931") {
    result
    message
  }
}
```

这会解除仓储包裹的锁定。仓储本身在仍允许时通过 `POST /api/v1/customer/storage-orders/{id}/cancel` 取消（状态为 `pending payment`、`confirmed`、`waiting for pickup` 或 `awaiting dropoff`；[REST 手册](/api/documentation#/paths/v1-customer-storage-orders-id--cancel/post)）。

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

```json
{ "result": true, "message": "Storage order cancelled", "data": { "id": 1024, "status": "cancelled" } }
```

- 仓储订单已支付的金额将退回客户余额。
- 处于其他任何状态的仓储订单返回 `403`。

**验证：** `422` 表示该状态不能取消。成功取消出库单后，第 9 步会再次列出这些包裹。

## 15. 错误处理

| 情形 | HTTP 状态 | 代码 | 集成方的处理 |
|---|---|---|---|
| 令牌缺失或已过期，或账号类型错误 | 401 | — | 以客户身份重新登录（第 4 步）。 |
| 仓储询价缺少 `warehouse_id` 或日期 | 400 | — | 发送 `warehouse_id`、`start_date` 和 `end_date`。 |
| 仓储订单校验失败（缺少仓储项尺寸、`end_date` 不晚于 `start_date`、缺少 `packaging_id`） | 422 | — | 读取 `errors`，更正字段后重新发送。 |
| 余额不足时支付仓储费用，或订单已全额支付 | 400 | — | 为余额充值（响应带有 `customer_balance`），若已支付则停止。 |
| 仓储付款的 `custom_amount` 超出允许范围 | 422 | — | 支付介于最低金额与剩余金额之间的金额。 |
| 仓储订单在当前状态下无法取消 | 403 | — | 请仓库处理该订单；不要重试。 |
| 该客户的出库功能已禁用 | 403 | — | 请商户为该客户账号启用出库。 |
| 服务代码未知，或找不到出库单 / 仓储订单 | 404 | — | 重新读取服务列表或检查已保存的 id。 |
| 包裹不可用、包裹来自不同仓库，或该仓库不提供该服务 | 422 | — | 重新读取第 9 步，并从同一仓库选择可用包裹。 |
| 运输服务无法为货件定价并拒绝该货件 | 422 | `unpriced_refused` | 选择其他服务或目的地；未创建任何内容。 |
| 余额不足时支付出库单 | 402 | — | 为余额充值后重试第 11 步。 |
| 出库单尚不可支付（等待人工报价）或金额无效 | 422 | — | 等待定价，重新查询出库单后再支付。 |
| 出库单在当前状态下无法取消 | 422 | — | 货件已在处理中；不要重试。 |
| 相同的 `Idempotency-Key` 与不同的请求体一起发送 | 409 | `IDEMPOTENCY_CONFLICT` | 不同的请求使用新的键。 |
| 使用相同 `Idempotency-Key` 的原始请求仍在处理中 | 409 | `IDEMPOTENCY_IN_PROGRESS` | 等待 `Retry-After` 秒后重新发送相同的请求。 |

## 验收清单

- [ ] 仓储配置返回仓库 `id`。
- [ ] 仓储询价返回价格，创建仓储订单返回 `data.id`。
- [ ] 仓储费用支付成功，**或**已确认钱包需要充值。
- [ ] 可用货物列表列出已收货的包裹（`storage_package_ids`）。
- [ ] 出库估价返回价格或 `has_items_needing_quote`，创建出库单返回 `id` 并锁定这些包裹。
- [ ] 出库单支付成功（或已理解 `402` / `422` 的含义）。
- [ ] 已有运单号后，公开追踪可以查到该货件。
- [ ] 取消出库单会释放包裹，**或**该状态不能取消。
- [ ] 使用相同的 `Idempotency-Key` 和请求体重复创建时，返回 `replayed: true` 且不会创建第二张订单。
- [ ] Webhook 设置返回 `recipient_type: customer`，且收到的事件通过 v2 签名校验。
