# Changliu Wmsapi

> 暢流（ChangLiu / changliu.com.tw）WMS 系統 API 串接知識與工具。涵蓋 JWT 認證、訂單查詢、 庫存與儲位查詢、入庫單查詢、收件人個資 AES-128-ECB 解密，以及兩個最容易卡住的眉角 （必帶瀏覽器 User-Agent 避開 Cloudflare 1010、來源 IP 白名單）。 只要任務牽涉到暢流 WMS、changliu.com.tw 的 API、`*.wms.changliu.com.tw` 網域、 order_query/stock_query/inbound 等端點、用 API 抓電商訂單（蝦皮/momo/PChome 等經暢流出貨）、 或解密暢流回傳的收件人姓名電話，就務必使用本 skill，即使使用者沒有明講「暢流」或「WMS」。

- Skill: `alanpai/changliu-wmsapi` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add alanpai/changliu-wmsapi`
- Raw SKILL.md: https://api.skillmd.com/api/skills/alanpai/changliu-wmsapi/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: AlanPai (https://skillmd.com/u/alanpai)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/alanpai/changliu-wmsapi

---


# 暢流 WMS API 串接

暢流（ChangLiu）是台灣的倉儲管理系統（WMS），透過 RESTful API 提供電商訂單、庫存、入庫等資料。
本 skill 提供串接所需的完整規格、可重用的 Python 客戶端，以及實測得出的關鍵眉角。

> ⚠️ **實測範圍但書（全檔適用）**：本檔標「✅ 實測」的數字與值域，多數只驗過**一家廠商的 API 帳號**
> （2026-08）。它們是「正常時長什麼樣」的參考值，不是暢流全域鐵律；換廠商、換租戶請重驗。

## 開始前先讀

- **完整 API 規格**：`references/api-reference.md`（端點、參數、回傳欄位、狀態碼、平台代碼、錯誤排除）。
  寫任何串接前先查這份，它是已實測 + 對照官方文件的權威來源。
- **可重用客戶端**：`scripts/wms_client.py`（認證快取、瀏覽器 UA、強制 IPv4、翻頁、個資解密都寫好了）。
  不要從頭手刻 HTTP 請求，直接用這支。
  ⚠️ **「會翻頁」不等於「一定拉完整」**：`_paged()` 要回的是 `(rows, 完不完整, 原因)`，
  高階查詢（`query_orders` / `query_inbound`）拉不完整就該丟 `WMSIncomplete`。
  拿結果去做覆蓋刪除／對帳前，先讀下面〈拉單完整性〉那一節。
  （`query_stock()` 打的是**非分頁版**端點，本來就不翻頁。）
  👉 完整性把關、`timeout` 參數、token 快取綁憑證版本這幾個介面屬**新版**客戶端；
  手上的檔案若還沒有，就照本檔對應章節的做法自己補上。
- **平台代碼對照**：`scripts/source_keys.py`（source_key ↔ 中文名，**85 個代碼／84 個名稱**：
  官方 84 個代碼＝83 家平台（`711go` 與 `iyugo` 同為「i預購」），
  另加 1 個官方清單外、實測遇過的 `outflow`＝倉庫出庫單（不是電商平台））。

## 五個一定要記住的眉角（不照做會卡住）

這幾點是實測踩過的雷，文件不一定講清楚，但每次串接都會遇到：

1. **必帶瀏覽器 User-Agent**。API 前面有 Cloudflare，用 Python 預設 UA 會被擋，回 `HTTP 403 + error code: 1010`。
   這不是暢流的錯誤而是 Cloudflare 的。一定要帶 `User-Agent: Mozilla/5.0 ... Chrome/...`。
2. **來源 IP 要在白名單內，而且是「一個廠商帳號一份」**。暢流後台「白名單管理」要加入呼叫端的對外公網 IP。
   未授權時 `result.ok=false`，message 實測原文為「您的IP[x.x.x.x]未在白名單中」，
   而且是**在取 token 那一步就被擋**（`token/authorize.php` 直接失敗），連線測試第一發就會現形。
   🔴 **白名單不是全域一份**：同一個 IP 對 A 廠商通，不代表對 B 廠商通。即使兩家共用**同一個**
   `{租戶}.wms.changliu.com.tw` 網域、只靠 API_ID／API_KEY 區分，白名單仍是各帳號各一份，
   要到**每一家的後台各加一次**。多帳號串接前先逐家跑一次取 token 測試，否則資料會在你不知情的
   狀況下只涵蓋通得了的那一家（少加一家的症狀＝那家一律取 token 失敗、其他家照常，很容易被
   誤讀成「那家憑證壞了」）。細節與實測範圍見 `references/api-reference.md` §0。
   ⚠️ 浮動 IP 會變動（實測家用線路 2026-09-08～11 四天換三次、一天內可換兩次），正式環境建議用固定的對外 IP；
   **為了開發／除錯臨時加進去的浮動 IP，事情做完要回頭請對方移除**（過幾天那個 IP 可能已經換人用）。
3. **雙棧網路要強制 IPv4**（2026-07 實測）。中華電信等雙棧網路下，Python 預設可能走 **IPv6** 出去，
   白名單錯誤訊息會出現 `2001:b011:...` 這種 IPv6 位址——IPv6 位址常輪換，不要拿去加白名單，
   正解是強制客戶端走 IPv4。做法有兩種，**能用第二種就別用第一種**：
   - `scripts/wms_client.py` 已內建（建構參數叫 `force_ipv4_dns=True`，預設開啟）。
     ⚠️ 它的手法是 patch `socket.getaddrinfo` 只回 `AF_INET`，**副作用及於整個 process
     且無法還原**——只要 new 過一個 `WMSClient`，同一支程式裡其他函式庫的連線也一起被改掉。
     一次性腳本沒差；長駐服務、或程式裡還有別的對外連線時要避開。
   - **用 httpx／aiohttp 就改綁本地端 IPv4**：`httpx.AsyncHTTPTransport(local_address="0.0.0.0")`。
     本地端綁 IPv4 wildcard 就只建得起 IPv4 連線，效果等同只回 `AF_INET`，但**不動全域**、
     不影響同一個 process 裡其他連線。（實戰專案的兩處正式呼叫點都用這招，並有守衛測試盯著。）
4. **個資不只密文那兩欄**。`receiver_name`、`receiver_phone`（收件人姓名、電話）為 AES-128-ECB 加密（見下）；
   若 API 帳號沒有「個資存取權限」，解出來會是 `****`（源頭遮罩，非解密失敗）。
   ⚠️ 但「只有這兩欄加密」**不等於**「其餘欄位可以整包安心存檔」，至少還有這幾類要防：
   - **入庫單的 `sender_name`／`sender_phone`**（見 `references/api-reference.md` 第 4 節）：
     欄名字面就是寄件人姓名與電話，**不在加密清單裡**。
   - **自由文字備註欄**：入庫單 `demo`（單頭）／`products[].notice`（品項），
     訂單 `buyer_msg`／`remark`／`notice`——依欄位定義就是讓人自由打字的格子，有被填入姓名電話的可能。
   - **收件地址** `receiver_info`（宅配單的 address／city／area／street）也是個資。
   👉 回應原文要存快照、寫進資料庫、寫 log 或貼進錯誤訊息之前，先把這些欄位剔除；
   清單與做法見〈安全須知〉與 `references/api-reference.md` 第 5 節。
   （範圍但書：目前唯一的入庫實拉樣本 `sender_*`／`demo`／`notice` 全是 null，
   所以「是不是明碼」「實際會被填什麼」都**尚未實測**，這是照欄位語意給的預防性提醒。）

5. **有請求頻率上限：每秒 3 發、可暫存 3 發，超過回 HTTP 409**（✅ 2026-09-13 實測）。回應標頭
   `x-ratelimit-limit: 3`／`x-ratelimit-burst: 3`／`x-ratelimit-remaining`；超一點被延遲放行、再超回 409
   （不是 JSON 錯誤，`raise_for_status()` 直接炸）。**額度很可能整組 API 帳號共用**——壓到每秒 1.7 發的
   逐日拉單仍偶爾撞到，因為正式服務用同一組憑證在分同一桶。做法：批次每發間隔 ≥ 0.6 秒、撞 409 退避重試
   （5→10→20 秒）、即時查詢至少自動重試一次、大量拉單放離峰。細節與範圍但書見 `references/api-reference.md` §0。
   ⚠ 同批實測還有三件：`order/count.php` 帶任何條件都回 0（不是歷史統計）；訂單狀態碼 `O`＝倉庫出庫單會混在
   訂單查詢裡；庫存查詢**沒有按庫位查**（帶庫位類參數全被靜默忽略，`wh` 亦然）——各見 §6、附錄 A、§3。
## 🔴 拉單完整性：這次到底拉完了嗎（實測踩過，會安靜漏單）

暢流**不會為錯誤的查詢報錯**——它安靜地回一份看起來很正常的資料。三種形狀都實際踩過：

| 踩到什麼 | 實際發生的事 | 怎麼防 |
|---|---|---|
| 分頁參數寫成 `page`（正確是 `nowpage`） | 未知參數被**靜默忽略**，每次都回第 1 頁；照 maxpage 跑 66～70 圈＝同一頁複製 66～70 份，算出來的統計全數作廢 | 每頁回檢暢流回的 `nowpage` 有沒有跟著走、整頁資料是不是全都收過了；總筆數剛好是「頁數×每頁筆數」的整數（3,500＝70×50、3,300＝66×50）就要起疑 |
| 帶了參數表以外的參數（例如想用 `send_num` 查訂單） | 條件**被忽略**、照樣回整批：帶與不帶回應一模一樣（50 筆、maxpage 48），沒有一筆是靶 | 只送 `references/api-reference.md` 第 2／4 節列出的參數；物流單號問不到暢流任何東西，只能整批拉回本地自己比對 |
| 條件的值是 falsy（例如 `status=0`） | 條件被當成**沒帶**：`inbound/query.php` 帶 `status=0` 回 `total=18`＝該廠商全部，而同樣不存在的 `status=5`／`9` 正常回 0 筆 | 查詢前把空值濾掉並**擋下**，別讓 `None`／`""`／`0` 進 query string |

⚠ 以上數字為 2026-08 實測，**每條各自只驗過一家廠商的帳號**；空字串（`""`）與 `order_query.php`
上的 falsy 行為屬同機制**推論、未實測**。

**白話後果**：把「只拿到這麼多」當成「只有這麼多」，再去做「本地有、暢流沒有就刪掉」這種對帳，
沒拉到的單會被整批當成過期資料刪掉，而畫面一路綠燈、上次同步時間照更新。
所以分頁結果一定要帶「完不完整」的旗標，判準見下。

### 「查不到」不等於「出錯」（最容易寫錯的一段）

暢流的「這個條件下沒有資料」有**兩種長相**，而且看端點而定，只認得其中一種就會誤判：

1. `result.ok=true` ＋ `rows` 是 `[]` 或 **`null`**，多半伴隨 `total=0`／`maxpage=0`
   （✅ 實測：`inbound/query.php`、`order/order_query.php`；`rows` 直接是 `null` 實測見於
   `inventory/stock_query_page.php`，其他端點未逐一驗證）
2. `result.ok=false` ＋ message 含「查無／無符合／沒有符合」（HTTP 仍 200；✅ 實測：`product/bom.php`）

- **兩種都要收斂成同一個結果**「這次沒查到」——只認一種，同一種現場狀況會一次做得成、一次做不成。
- 🔴 **取 `rows` 的寫法有兩個陷阱**：`data.get("rows", [])` 在「key 在、值是 `null`」時 default
  不生效，會拿到 `None`，`list(None)` 當場 TypeError；`data.get("rows") or []` 不會爆，
  但會把 `null`／`{}`／`0`／`""` 全吃成空陣列，「回應壞掉」與「真的查無」就分不出來。
  保守寫法是**先判型別**：拿到的不是 list 就當「回應結構異常」處理，不要一律當成空。
- **「真的一張都沒有」**的判準要同時滿足「**第 1 頁**」＋「空 rows」＋「`total=0`」；
  第 2 頁以後的空白是「暢流剛說共 N 頁、現在又說查無」的**前後不一致**，絕不可當成翻完了。
  換句話說：第 1 頁查無＝真的沒有（完整，該刪就刪）；第 2 頁以後查無＝不完整，停手別刪。
- 🔴 **`product/bom.php` 要再分一層**：「沒問到」（連線失敗、HTTP 錯誤頁、回應結構不對、
  其他 `ok=false`）**絕不可以**當成「暢流說它不是組合品」——後者會讓組合品不展開元件，
  撿貨單看起來完全正常、貨卻少出。權威答案要三條件同時成立（HTTP 200 ＋ `ok=false` ＋
  訊息**含「非組合品」**），其餘一律重試，耗盡才擋下。
  ⚠ 暢流那句「查無符合的資料，或提供的SKU商品非組合品」**同時含「查無」與「非組合品」**，
  只比對「查無」必然誤判。細節見 `references/api-reference.md` §0.5 與 §4.5。
- ⚠ 白名單被拒、權限不足的訊息**不含**那三個關鍵詞，那仍然是失敗——別把「連不上」讀成「沒有資料」。
- 建議把查無類訊息獨立成一個例外（本 skill 客戶端用 `WMSEmpty`，`WMSError` 的子類）並**分開接**：
  接到 `WMSEmpty` 是「沒資料」，接到其他 `WMSError` 才是真的失敗。

## 認證流程

```
POST {base}/token/authorize.php
  Header: Authorization: Basic {base64(API_ID:API_KEY)}, User-Agent: {瀏覽器UA}
  → data.access_token（效期 1 小時）
之後每支 API: Authorization: Bearer {access_token}
```
回應統一格式：`result.ok` / `result.message` / `data`。詳見 `references/api-reference.md` 第 1 節。

### token 快取的三個坑（長駐服務才會遇到）

Token 效期 1 小時，**一定要快取**（提前 5 分鐘換新即可）。但只看效期是不夠的：

1. **快取鍵要含「憑證版本」。** 管理者在後台重設過 API 憑證之後，只看效期的長駐服務
   最長會有 **55 分鐘**還在用舊 token——後台按「連線測試」顯示通過、員工那邊卻刷不出單，
   兩邊講的話不一樣。做法：快取值存成 `(token, 取得時刻, 憑證版本)`，三者任一不符就重新取；
   「憑證版本」拿資料庫裡那筆憑證的更新時間字串當比對值就夠（資料驅動，多程序部署下
   每個程序各自比對，不必互相通知去清誰的快取）。
2. **多家廠商（多租戶）要逐家各自快取。** 一張 token 只對換它的那組憑證有效，
   快取鍵請用廠商 ID，不要用單一全域變數。
3. **做「連線測試」要用一個快取必定為空的客戶端。** 沿用共用的那個單例等於測到舊憑證
   換來的舊 token，管理者會拿到一個「通過」的假答案。

對應的客戶端介面：`WMSClient(creds_version=...)`、`set_credentials()`（換憑證會自動作廢 token）、
`invalidate_token()`（測試前先清）。

## 個資解密配方（實測成功）

- 演算法 **AES-128-ECB**；金鑰 = 後台「個資解密金鑰」字串的**前 16 字元（UTF-8 bytes）**
- 密文 **Base64**；Padding **PKCS7**

```python
import base64
from Crypto.Cipher import AES
def decrypt_pii(b64_text, full_key):
    ct = base64.b64decode(b64_text)
    pt = AES.new(full_key.encode("utf-8")[:16], AES.MODE_ECB).decrypt(ct)
    p = pt[-1]
    if 1 <= p <= 16 and pt[-p:] == bytes([p]) * p:
        pt = pt[:-p]
    return pt.decode("utf-8")
```
（`scripts/wms_client.py` 的 `decrypt()` 已內建此邏輯。）

## 時間一律台灣時間（✅ 2026-08 實測）

API 回的時間字串**不帶時區標示，值就是台灣時間（UTC+8）**。若系統存世界標準時間（UTC），
讀進來要先補 +08:00 再轉——這種錯**不會報錯**，只會讓某些數字安靜地差 8 小時。
已逐一驗過的欄位：`order_date`、`finish_time`、`order_logistics.php` 時間軸、入庫紀錄 `inbound_date`；
其餘時間欄同族推定、**未逐一驗**。

⚠️ **不要拿暢流後台畫面去校正 API**：畫面「核貨時間」與 API `finish_time` 一秒不差，
但畫面「上傳SHOPEE」那一格少 8:00:00 整（＝該格用世界標準時間顯示，是暢流的畫面問題）。
對不上時以 API 為準，別在程式裡偏移時間。細節與範圍但書（實拉樣本僅一家廠商三張單、
「撿貨時間」那格待補驗）見 `references/api-reference.md` 第 0 節。

## 常用端點速查

| 任務 | 端點 | 重點 |
|------|------|------|
| 查訂單 | `GET order/order_query.php` | 可用 order_no / date_min,date_max / status / source_key；分頁一律 `nowpage`＋`pagesize`（上限 50/頁）。⚠ `date_*` 篩的是**訂單日期**不是出貨日——只拉「今天」會漏掉前幾天下單、今天才要出的積壓單，務必往回拉數天。⚠ 參數表以外的參數會被**靜默忽略、照樣回整批** |
| 查庫存+儲位 | `GET inventory/stock_query.php` | 用 sku 或 item_no 查，**一次可帶 50 個**（逐個打實測慢 10 倍：141 個商品 21 秒 → 批次 2～3 秒）；儲位名在 `data.rows[].spaces[].name`，**倉別＝名字用 `-` 切開的第一段**（`warehouse` 欄實測是 null，不能用）。⚠ **`occupied_stock`（佔用）批次查會歸戶錯誤**——同一張單押的多個商品，佔用會加總全掛在第一個商品上、其餘顯 0；**看佔用／可用庫存必須單查**。佔用有**兩層語意**：商品層＝所有未出貨單（F/W/P）加總、儲位層＝僅「轉揀貨(P)」已指定撿貨格的量（F/W 階段儲位層恆 0）；可用庫存 API 不給、自己算 stock−佔用（✅2026-09 實測，見 api-reference §3）。**核貨時就從記了儲位層佔用的那一格扣、扣的量＝佔用量**，同商品其他格不動（✅2026-09-11 三個 SKU 核貨前後對照） |
| 全量列舉庫存 | `GET inventory/stock_query_page.php` | 分頁版（`nowpage`＋`pagesize`，上限 50）；**不帶 sku 就是整倉一頁頁列舉**；⚠ 組合品在這支查不到（`total=0`／`rows=null`）。詳見 api-reference §3.1 |
| 查入庫單 | `GET inbound/query.php` | 用 order_no / build_date / supplier；rows[].products[] 是入庫商品。⚠️ 登記是**逐品項**的（實測一張單可跨 12.5~97.5 分鐘），判「收完了沒」看每個品項有沒有 `history`，**別看 `status`**（入庫狀態碼官方無碼表，附錄 A 那張是訂單的） |
| 查商品目錄 | `GET product/pro_query.php` | 🔴 **沒有批量**，一次只能一個 sku；要一批就整本拉（不帶 sku、`pagesize=100`，上限就是 100）。每列帶 `combination`／`processed` 官方旗標 |
| 查商品明細 | `GET product/pro_detail.php` | `sku=` 或 `item_no=` 皆可；真正的規格層在 `specifications[]`（各規格的 `item_no` 與 `stock`） |
| 取面單（寄件單） | `POST logistics/label.php` | JSON body `{"order_no":"…"}`；回 `label_url`／`limit_time`／`send_num`。黑貓／新竹／MOMO甲配／店到家的面單**只有這條路**（它們的 `print_url` 實測恆空）。⚠ 已不是唯一的 POST——取號那兩支也是 POST 且**會寫入**，見下一列 |
| 幫自家單取黑貓托運號 | `POST logistics/confirm.php` → `POST logistics/request.php` | 🔴 request **真取號（寫入、不可逆）**；confirm 只問「走哪家＋可選尺寸」（✅ 實測無副作用）。request body `{"order_no","size":[{"code","quantity"}]}`。**多箱＝一箱一號＋一箱一張面單，貨到付款的代收金額由暢流拆到各箱**（✅2026-09-14 重驗，⚠ 只驗過**轉單中**的人工開單、轉揀貨的單還沒驗；8/25「一單恆一號」、9/11「N 號但面單只有最後一號」都已過時）。⚠ 回 `ok=true` 但 `send_num` 是空的也遇過——打完一定回查訂單。平台單不必也不該打（平台自取號）。全文 `references/api-reference.md` §6.6 |

完整參數與回傳欄位見 `references/api-reference.md`。其他端點（商品、物流、POS、新增/取消類）清單也在該檔第 6 節。

> 🔴 第 6 節那張表把**查詢**與**會改暢流資料**的端點（新增／取消／揀貨／庫存更新／入庫單新增取消／
> 物流確認、請求、完成）混排在一起，表內已逐支標記讀／寫。**預設只打查詢端點**，
> 並且別拿 GET/POST 當判準——`logistics/label.php` 是 POST 但實測零副作用，
> 掛 GET 的 `inventory/stock_allocation.php` 反而看不出是查詢還是執行。

### 🔴 訂單的「物流」有三個欄位，別選錯

（✅ 2026-08 實測；完整值域與證據見 `references/api-reference.md` 第 2 節〈物流三欄〉）

- `freight_name`（運送方式名稱）＝**顯示與分組就用這欄**，人看得懂、值域＝該租戶後台「運送方式」清單。
- `logistics`（**取號渠道**）＝物流單號走哪條線取。**官方文件漏記，但實測每一列都會回**；
  實拉值域 `cat`／`shopee`／`moplus`／`momo_pelican`／`pelican`／`none`。
  ⚠ 渠道**不等於**誰家車來載——新竹物流的單 `logistics` 是 `shopee`（＝蝦皮代辦取號）。
- `logistics_code`（物流代碼）＝**髒欄位，只存參考**：值域混形（多數其實是中文運送方式名原樣填入），
  且與 `freight_name` 兩個方向都不是一對一（mo店+ 三種運法代碼全是 `moplus`）——拿它分組會**靜默併錯**。

三欄都是**下單匯入就有值**，不用等取號。⚠ 值域只實拉過一家廠商（自然風），換廠商請重驗；
另外 `logistics` 這個名字在入庫單查詢也有一個，是**同名不同義**的另一個欄位。

### ⚠ 用狀態碼（`status`）篩單前先讀這三句

（✅ 2026-08 實測，範圍＝單一廠商帳號自然風）

1. **狀態不是每張單都走完 P→C→S 的階梯**：蝦皮來源的黑貓單近 21 天 12 張 **12/12 直接停在 S**、
   0 張停在 P 或 C（同期非蝦皮的黑貓單則停得住 C）。只拉 `W,P,C` 會**整批漏單**。
2. **S（已出貨）不代表貨已離倉**：那個 S 是回報電商平台那一秒設的（時間＝`finish_time`+2 秒），
   查 `order_logistics.php` 看不到物流的集貨／轉運紀錄。
3. **物流單號（`send_num`）要到轉揀貨（P）才有**（W／F 實測 0%）；廠商自送／自取類永遠不會有。

細節、樣本數與尚未證實的部分，見 `references/api-reference.md` 附錄 A 下方〈狀態碼的實務眉角〉。

### 🔴 商品與條碼：三個最容易踩的坑（✅ 2026-08 實測）

1. **條碼登記在 `item_no`，不是 `sku`**。暢流商品主檔沒有獨立的條碼欄位；
   `sku` 是賣場／內部代號（例 `A020151-02-CH`），實體條碼（EAN-13、ITF-14）登記在
   `item_no`（例 `4710088832351`）。**拿掃到的條碼去比 `sku` 會全部比不中。**
   而且 `item_no` 非空 ≠ 架上掃得到（同一欄也被拿來填自家料號、`0`、`C-0000008` 這種值），
   要用「長度 8～14 碼＋GS1 檢查碼實算」判；整箱商品可能登的是 14 碼外箱碼，
   判準**不可收窄成 12/13 碼**。同一條碼可能掛多個 SKU → **禁止拿條碼反查品項**，
   只能拿來驗「這串碼屬不屬於指定品項」。
2. **`product/pro_query.php` 沒有批量**。`sku=A,B` 回 0 筆不報錯、`sku[]=A&sku[]=B`
   只回第一個、重複鍵只回最後一個——**全部靜默**，看起來像成功。
   要一批商品就**整本拉**（不帶 sku、`pagesize=100`），不要自己拼批次。
   ⚠ 整本拉回來的每一列，`sku` 是**陣列**（例 `["MP-0032"]`）、`spec` 是 **list**（`[{sku, item_no, name}]`，真正的倉端品項在這裡）；
   直接 `str(row["sku"])` 會變成 `"['MP-0032']"`，拿去查庫存**回 0 筆也不報錯**（✅2026-09-10 踩過）——從 `spec[]` 逐筆取。
3. **`pagesize` 上限逐端點不同**：訂單／入庫單 50、商品目錄 **100**；
   超過上限不報錯，只是靜默截到上限（商品目錄實測開 200／500／1000／2000 都照樣每頁回 100）。

判「是不是組合品」用官方旗標 `combination`（`pro_query`／`pro_detail` 每一列都有），
不要看貨號字頭。細節、欄位表與**實測範圍但書**（多數只驗過一家租戶）見
`references/api-reference.md` §4.7～§4.9。

## 使用客戶端的範例

`scripts/wms_client.py` 以環境變數或建構參數讀取憑證（**不含任何硬編碼金鑰**）：

```python
from wms_client import WMSClient
# 憑證來自環境變數 CHANGLIU_BASE_URL / CHANGLIU_API_ID / CHANGLIU_API_KEY / CHANGLIU_DECRYPT_KEY
c = WMSClient()
orders = c.query_orders(date_min="2026/06/01", date_max="2026/06/30", source_key="shopee")
name = c.decrypt(orders[0]["receiver_name"])           # 解出收件人姓名（若有權限）
stock = c.query_stock(sku="C010014-THL")               # 查庫存與儲位
inbound = c.query_inbound(order_no="2606180002")       # 查入庫單
```

## 逾時要設多少（有實測數據，別憑感覺）

暢流正常回應 **1~2 秒**。三筆不同時間、不同情境的實測：三家廠商並行查 1.0~1.2 秒；
換 token 1.4 秒、單筆查詢 0.64~0.75 秒；訂單拉 10 頁共 12.8 秒（每頁約 1.28 秒）。
⚠️ 這些樣本來自同一台暢流主機的少數幾家廠商，是「正常時多快」的參考值，不是保證上限。

分層設，別一個值走天下：

| 情境 | 單次逾時 | 為什麼 |
|------|---------|--------|
| 背景批次拉單 | 15 秒 | 留餘裕給偶發的慢回應；翻頁是多次請求，每次各自計時 |
| 有人在畫面前等（連線測試、現場刷單） | 8 秒 | 沿用 15 秒的話「取 token＋查詢」兩段最壞 30 秒，使用者只能乾等 |
| 整支互動流程 | 再包一個 20 秒總上限 | 保證畫面一定回得來（例：`asyncio.wait_for(...)`） |

`scripts/wms_client.py` 建議預設 15 秒（舊版寫死 40 秒），要調就 `WMSClient(timeout=8)`。
注意它是**每次請求**各算，不是整批的總時間（自動翻頁會打很多次）。

## 失敗要怎麼判讀

例外訊息是自由文字，呼叫端拿字串去比對很脆。建議把任何一次失敗翻成一個**封閉的分類碼**
再落庫，由上往下第一個命中者勝：

| 順序 | 訊號 | 分類碼 | 白話 |
|------|------|--------|------|
| 1 | 訊息或回應內容含「白名單」 | `ip_not_whitelisted` | 這台機器的對外 IP 沒加進**這家**廠商的後台 |
| 2 | HTTP 403 且內容含 `1010` | `blocked_1010` | 被 Cloudflare 擋（多半是沒帶瀏覽器 UA） |
| 3 | 連線層例外（逾時、連不上、TLS 失敗） | `timeout` | 網路或對方慢，稍後再試 |
| 4 | 其餘，且**失敗在取 token 那一步** | `auth_failed` | 憑證錯（API_ID／API_KEY 不對） |
| 5 | 其餘，且**token 拿到了、查詢那步才失敗** | `query_denied` | 登得進去，但這支／這筆查不了（權限不足） |

第 4、5 條「用失敗在哪一步分憑證錯與權限不足」是文件不會寫、但每次串接都用得到的判讀技巧。
⚠️ `1010` 要**兩步都檢查**：`scripts/wms_client.py` 只在取 token 那步認 1010，
查詢那步被 Cloudflare 擋會變成「回應非 JSON（HTTP 403）」這種看不懂的訊息，自己包一層時要補上。
🔴 分類碼可以落庫，但**回應原文只寫伺服器 log、不要落庫也不要顯示**——
暢流的自由文字備註欄可能被人填進收件人姓名電話。

## 安全須知

- **金鑰絕不寫進程式或 skill**。一律用環境變數或各專案的設定檔（並加入 .gitignore）。
- API_ID、API_KEY、個資解密金鑰外洩等同帳號被接管，請妥善保管。
- 解密後的姓名/電話/地址屬個資，依個資法妥善處理，勿外流或寫入版控。
- ⚠️ **「個資」不等於「那兩個密文欄」**：入庫單的 `sender_name`／`sender_phone` 不在加密範圍內，
  `demo`／`products[].notice`（訂單則是 `buyer_msg`／`remark`／`notice`）是自由文字欄可能藏姓名電話。
  **回應原文整包存檔／寫進資料庫（快照、除錯用 dump）之前先剔除這些欄位**（見眉角 4）。
  這些欄位會不會有值取決於各租戶怎麼建單，**不能假設它們永遠是空的**。
- **寫 log 也算落地**：錯誤處理時只寫例外類別與訊息，不要把回應原文整段寫進 log。
  要落庫的請落上面那組**錯誤分類碼**，不要落原文。
- 用不到收件人個資就**不要申請「個資存取權限」、不要保管解密金鑰**：沒權限拿到的本來就是
  密文或 `****`，這是成本最低的防線。
- 🔴 **暢流有一批端點會真的改倉庫資料**（新增訂單／取消訂單／訂單揀貨／新增商品／庫存更新／
  新增與取消入庫單／物流確認、請求、完成），它們和查詢端點**混在同一張表**裡
  （`references/api-reference.md` 第 6 節，表內已逐支標記讀／寫）。
  **除非任務明確要求寫入，預設只打查詢端點。**
- ✅ **寫入端點的白名單要寫進客戶端，不要靠自律**：把允許打的路徑列成清單，不在清單的直接丟例外。
  這樣呼叫端寫錯、或日後有人「順手加一支」，門仍然關著；靠「我記得別打那幾支」遲早會破。
- ⚠️ **不要拿 GET/POST 當「會不會改資料」的判準**，兩個方向都會判錯：
  `logistics/label.php`（取寄件單網址）是 POST 但**沒有副作用**（✅ 2026-08 實測：16 張不同的
  **已取號**單、40 餘次呼叫，每次呼叫前後各回查一次訂單，物流單號與狀態碼全程零異動；
  ⚠ 未取號的單完全沒測）；而掛 GET 的 `inventory/stock_allocation.php`（庫存調撥）
  反而看不出是查調撥紀錄還是執行調撥（📄 未實測）。

## 面單（寄件單）怎麼拿（最容易照文件想像卡死的一題）

- 訂單查詢的 `print_url` **不是每張單都有**：實測**黑貓／新竹／MOMO甲配／店到家恆空**
  （近 30 天逐狀態切片，出完貨也不會長出來，不是時間差），只有 mo店+ 走 momo 第三方物流的
  宅配通那條 100% 有值。決定有沒有的是**物流商／運送方式**，不是來源代碼——同一個
  `source_key=moplus` 走黑貓時一樣全空。另外 F 待處理／W 轉單中狀態下，
  `print_url`／`send_num`／`AllPayLogisticsID` 三欄都必空。
- 這幾家的面單**只有 `POST logistics/label.php` 拿得到**：必須送 JSON body
  `{"order_no":"…"}`（form-data 會被回「請輸入JSON格式的資料」），回
  `label_url`／`limit_time`／`send_num`。
- 🔴 `label_url` **回的是網頁不是 PDF**、**效期以回傳的 `limit_time` 為準、別寫死**（早期實測約 30 分鐘；2026-08-25 兩批同租戶實測皆為＋4 小時）——無論多長，取到就儘快抓／渲染，
  不可先收集一整批網址再慢慢處理。多箱單（✅2026-09-14）一箱一張面單、渲染成 N 頁，各頁文字層有自己的物流單號，但**沒有箱序**；
  ⚠ 2026-09-11 還遇過「3 個號、頁面只有最後一號那一張」（暢流其後已改）——批次印單要驗「頁數＝號碼數」。
- 🔴 它是 POST：實測對**已取號**的單零副作用，但**未取號的單從沒測過**，最壞情況是觸發
  取號＝真寫入、不可逆。保守做法是在客戶端寫死「沒有有效物流單號就不打」。
- 完整規格（多箱行為、面單轉成可印檔案的三條路、黑貓渲染的三個必要條件）見
  `references/api-reference.md` §6.5。⚠ 上述數據取自**單一廠商 API 帳號、2026-08**，
  不是暢流全域鐵律。

## 物流單號 `send_num` 的三個坑（很常踩）

實測整理（樣本＝單一廠商，細節與數字見 `references/api-reference.md` 附錄 A-1）：

1. **「沒有號碼」有兩種長相**。空值＝還沒取號（**取號在 W 轉單中 → P 轉揀貨 之間**，
   W／F 階段必定沒有）；**一串全 0**（實測 8 碼）＝手開單／Excel 匯入這類暢流本來就不給號的單。
   判「有沒有號」要看「**去掉所有 0 之後還有沒有字元**」，不要比對字串 `"00000000"`；
   而且**絕不可拿全 0 那批當索引鍵**，它們會全部撞在一起。
   另外，走廠商自送／自取的單（取號渠道 `logistics` = `none`）永遠不會有暢流物流單號。
2. **一單多箱是逗號串**。黑貓多箱時，暢流把每箱的號碼**用半形逗號串成一格**塞在 `send_num`，
   拿整串比對單一箱的條碼永遠對不上，要先拆開。號碼格式也跨平台不統一（8~15 碼、有純數字有英數混合），
   不要用長度或字集去驗。
3. **不能用物流單號反查暢流**。`order_query.php` 沒有這類參數，帶了會被**靜默忽略、照樣回整批**
   （不是查無，見上面〈拉單完整性〉）；`order_logistics.php` 只認 `order_no`。
   要反查只能自己拉日期區間、在本地建索引。

## 折扣資料的限制（常被問）

`discounts`（賣家折扣/平台補助）**只有這些來源提供**（下面寫的是 `source_key` 代碼，括號是平台名）：
`shopline`(SHOPLINE)、`cyberbiz`(Cyberbiz)、`shopee`(蝦皮)、`91appapi`(91APP(API))／`_91app`(91APP(Excel))、
`qdm`(QDM)、`shopify`(Shopify)。
⚠ **不是 `91app`、也不是大寫 `QDM`**——這兩個在 `source_keys.py` 裡查無此代碼，拿去送 `source_key` 會篩不到東西。
momo、moplus、coupang 等**沒有 discounts**（一律空陣列），只能拿到 `total_price` 總額。
蝦皮折扣 key：`voucher_from_seller`(賣場券)、`voucher_from_shopee`(蝦皮券)、`coins`(蝦幣)。

