> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.orderupb2b.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.orderupb2b.com/_mcp/server.

# Upsert 與冪等

客戶、商品、規格（SKU）的建立端點都是同一套 upsert 規則：同一支端點，依你送出的欄位自動判斷這一筆該新增、更新，還是去重成更新既有的一筆。目的是讓 ERP 端可以重複推送同一份資料而不會產生重複紀錄，即使中途重試也一樣安全。

後台使用者是唯一的例外，規則不同，見本頁最後一節。

## 三條判斷規則

依序判斷：

1. **body 帶 `id`** —— 一律視為更新該筆，直接依 `id` 找到那筆資料並更新，不會再去比對 `outerSysCode`。
2. **body 沒帶 `id`，但 `outerSysCode` 命中既有資料** —— 改為更新命中的那一筆（這就是「冪等去重」：同一份 `outerSysCode` 重送多次，只會更新同一筆，不會越送越多筆）。
3. **兩者都沒有命中** —— 新增一筆。

規格（SKU）的去重多一個限制：**比對範圍限定在同一個商品之內。** 判斷「這個 `outerSysCode` 是否已存在」時，比對的是「同一個 `productId` 底下」的既有規格，不同商品下即使 `outerSysCode` 完全相同，也不會互相匹配、也不會互相衝突。這是因為 SKU 代碼的唯一性本來就是以商品為範圍，不是全站唯一。

批次端點（`/v1/customers/batch`、`/v1/products/batch`、`/v1/skus/batch`）逐筆套用同一套規則，一筆一筆各自判斷新增或更新，互不影響；哪一筆成功、哪一筆失敗、是新增還是更新，都會在回應的逐筆結果裡分別回報。

### 三個分支各自長什麼樣子

以 `POST /v1/customers` 為例，假設 `id=501`、`outerSysCode="ERP-C-1001"` 的客戶已經存在：

| 分支                           | 送出的 body                                                        | 結果                                |
| ---------------------------- | --------------------------------------------------------------- | --------------------------------- |
| 1. 帶 `id`                    | `{ "id": 501, "cellPhone": "02-2345-6789" }`                    | 更新 `id=501` 那筆，不比對 `outerSysCode` |
| 2. 不帶 `id`，`outerSysCode` 命中 | `{ "outerSysCode": "ERP-C-1001", "cellPhone": "02-2345-6789" }` | `ERP-C-1001` 已存在（就是 501），改為更新 501 |
| 3. 都沒命中                      | `{ "outerSysCode": "ERP-C-2002", "name": "新客戶股份有限公司" }`         | `ERP-C-2002` 不存在，新增一筆新客戶          |

分支 1 與分支 2 的差別只在有沒有帶 `id`——兩者最終都會落到「更新 501」，但走的是不同的判斷路徑；重送分支 2 的 body 一百次，也只會有一筆 `id=501` 的客戶被反覆更新，不會變成一百筆客戶。

## 部分合併：省略的欄位永遠不會被清空

更新（不管是依 `id` 直接更新，還是依 `outerSysCode` 去重出來的更新）一律是部分合併：**只有你實際送出的欄位會被寫入，省略的欄位維持原本的值。** 這代表：

* 只想改一個欄位時，body 裡只需要放那一個欄位，不需要把整筆資料重新送一次。
* 省略某個欄位不會把它清空或重設成預設值——例如更新客戶時只送 `shippingAddress`，客戶原本的 `name`、`shortName`、`cellPhone` 都會維持不變。
* 這條規則只適用於更新。**新增**時省略字串欄位視為空字串，省略數字欄位（例如規格的 `price`、`sellPrice`、`quantityPrecision`）視為 0，因為新增沒有「原本的值」可以保留。

實際看一次：客戶 `id=501` 目前長這樣——

**`更新前：GET /v1/customers/501`**

```json title="更新前：GET /v1/customers/501"
{
  "id": 501,
  "outerSysCode": "ERP-C-1001",
  "name": "測試客戶股份有限公司",
  "shortName": "測試客戶",
  "cellPhone": "02-1234-5678",
  "shippingAddress": "台北市信義區松高路1號",
  "internalUserId": 12,
  "status": "Active"
}
```

只送 `cellPhone` 這一個欄位去更新：

**`PUT /v1/customers/501`**

```json title="PUT /v1/customers/501"
{
  "cellPhone": "02-2345-6789"
}
```

更新後再查一次，只有 `cellPhone` 變了，其餘欄位（包含沒有出現在請求裡的 `name`、`shortName`、`shippingAddress`、`internalUserId`）原封不動：

**`更新後：再次 GET /v1/customers/501`**

```json title="更新後：再次 GET /v1/customers/501"
{
  "id": 501,
  "outerSysCode": "ERP-C-1001",
  "name": "測試客戶股份有限公司",
  "shortName": "測試客戶",
  "cellPhone": "02-2345-6789",
  "shippingAddress": "台北市信義區松高路1號",
  "internalUserId": 12,
  "status": "Active"
}
```

`status` 不受這條規則影響，因為它本來就不是 upsert 請求裡的欄位——透過本 API 建立的客戶一律是 `Active`，狀態變更由後台維護。

## PUT /\{id}：路徑上的 id 永遠優先

`PUT /v1/customers/{id}`、`PUT /v1/products/{id}`、`PUT /v1/skus/{id}` 這三支路徑帶 id 的更新端點，**路徑上的 `id` 永遠優先於 body 裡的 `id`**——即使 body 另外指定了一個不同的 `id`，也一律以路徑上的為準，body 裡的 `id` 會被忽略。這是為了避免呼叫端不小心把更新導向另一筆資料：路徑已經明確表示「要更新哪一筆」，body 的 `id` 沒有機會覆蓋這個意圖。

**`PUT /v1/customers/501`**

```json title="PUT /v1/customers/501"
{
  "id": 999,
  "cellPhone": "02-2345-6789"
}
```

這筆請求更新的是路徑上的 `501`，body 裡的 `999` 完全被忽略——不會去更新 `id=999` 那筆，也不會因為 `999` 不存在而報錯。回應裡的 `id` 也會是 `501`。

## 例外：POST /v1/internal-users 沒有去重

後台使用者的建立端點不遵循上面的三條規則——**它永遠是新增，沒有 `outerSysCode` 去重更新這個分支。** 即使送出的 `outerSysCode` 跟某個既有後台使用者相同，也不會被視為要更新那一筆，而是照樣嘗試新增一筆新的。

因為沒有去重機制，重複送出同一份資料會撞到帳號唯一性：**同一租戶內 `account` 重複，會回傳 `400 account_already_used`**，不會更新既有帳號，也不會被靜默忽略。如果你需要更新既有後台使用者的資料（例如改姓名、改角色），必須帶 `id` 呼叫 `PUT /v1/internal-users/{id}`，`POST` 這支端點永遠走新增這條路。

會有這個例外，是因為後台使用者是登入帳號，`account` 本身就必須全租戶唯一，語意上不適合像客戶／商品／規格那樣用 `outerSysCode` 去重合併——重複的 `account` 代表操作本身有問題，需要呼叫端明確處理，而不是悄悄併進既有帳號。