# CR-015　`merchant-service` 契約值域與 DDL 脫鉤、且「商店型態 vs 業別」為**兩個正交維度**

| 項目 | 內容 |
|---|---|
| 文件編號 | CR-015 |
| 版本 | **1.1（BEAM PMO 2026-09-22 裁定：A 採雙欄位、B～G 一次核准；**已執行並真機實測通過**，見 §4／§5）** |
| 提出日 | 2026-09-22 |
| 提出者 | WorkBuddy AI（開發團隊） |
| 緣起 | `GOV-006` 2.3.2（**C-4** `merchant-service`）動工前之查證 |
| 影響文件 | `contracts/openapi/merchant-service.yaml`（產生器 `tools/gen_openapi.py`）、`SCS-014`、`SDD-001 §5.9`、`SDD-006 §3.4`（錯誤碼） |
| 關聯前例 | `CR-002`（分期值域與 DDL 不一致）、`CR-008`（分期雙軸拆分）、`CR-012`（兌換列舉與 DDL 完全脫鉤 → **雙欄位**） |

---

## 1. 緣起

`merchant-service` 目前**僅有骨架**（`MerchantServiceApplication` ＋ `LayeringTest`），
DB（`CC_MCH`：`MERCHANTS`／`MERCHANT_SETTLEMENTS`）與契約（3 端點）均已就緒。
依「先查證 → 規格 → 實作」紀律，動工前逐項比對**契約列舉**與**DDL CHECK（實測）**，
發現 **3 處值域脫鉤 ＋ 3 項契約欄位無處可存**——若直接實作，將立即撞
`ORA-02290`（寫入 DDL 不接受之值）或**靜默丟棄**契約欄位（客戶欄位消失）。

---

## 2. 缺口明細（DDL 為 2026-09-22 實測值，`all_constraints.search_condition_vc`）

### 2.1 ⚠️ 核心發現：`merchantType` 是**兩個正交維度**，非單純值域不符

| 來源 | 欄位 | 值域 | 語意 |
|---|---|---|---|
| **DDL（實測）** | `MERCHANTS.MERCHANT_TYPE` | `RETAIL`／`EC`／`MOTO`／`RESTAURANT`／`TRAVEL`／`SUPERMARKET`／`OTHER` | **業別**（做什麼生意） |
| **契約** | `CreateMerchantRequest.merchantType` | `SINGLE`／`FRANCHISE`／`CHAIN`／`GROUP` | **商店型態**（單店／加盟／連鎖／集團） |

兩者**無一交集**，且語意分屬不同軸——**同一欄位不可能同時表達**：
一間「超市」（業別 `SUPERMARKET`）可以是「連鎖」（型態 `CHAIN`）。
此與 `CR-008`（分期：通路 × 業務型態）、`CR-012`（兌換：標的 × 折抵）**同一類問題**，
解法應為**雙欄位**，而非二選一。

### 2.2 `status` 值域不一致（3 vs 4）

| 來源 | 值域 | 差異 |
|---|---|---|
| DDL（實測） | `ACTIVE`／`SUSPENDED`／`TERMINATED` | — |
| 契約 | `APPLIED`／`ACTIVE`／`SUSPENDED`／`CLOSED` | 契約多 `APPLIED`（申請中）；`CLOSED` ↔ `TERMINATED` 名稱不同 |

### 2.3 `MERCHANT_SETTLEMENTS.status` 值域完全脫鉤（2 vs 4）

| 來源 | 值域 | 語意粒度 |
|---|---|---|
| DDL（實測） | `PENDING`／`SETTLED` | 入帳面（待處理／已結算） |
| 契約 | `SUBMITTED`／`VERIFIED`／`EXCEPTION`／`PAID` | 請款流程面（送出／核對／異常／已付） |

契約另有 `exceptionReason` 欄位（`nullable`）——DDL **無對應欄位**。

### 2.4 契約欄位無處可存（3 項）

| 契約欄位 | 用途 | DDL 現況 |
|---|---|---|
| `CreateMerchantRequest.parentMerchantId` | 集團店上層（`description` 明載「`parentMerchantId` 表集團店」） | **無此欄** |
| `CreateMerchantRequest.acceptedCardTypes`（`string[]`） | 受理卡種 | **無此欄**（`TERMINAL_IDS` 為終端機陣列，語意不同） |
| `MerchantSettlementRequest.batchNo` | 請款批次號 | **無此欄**；僅有 `BATCH_COUNT NUMBER(10)`（批次**筆數**，語意不同） |

### 2.5 附帶：`MerchantSettlementView` 未回傳 DDL 既有欄位

`TAX_AMOUNT`／`CURRENCY`／`SETTLEMENT_DATE` 於 DDL 存在且為 `NOT NULL`，
契約回應僅有 `grossAmount`／`feeAmount`／`netAmount`——對帳單（`ACQ-4.3`）
所需之稅額與幣別**取不到**。

---

## 3. 建議方案（供裁定）

> 原則依 `SDD-005` **SM-08**：「值域以**可持久化之 DDL CHECK** 為權威；
> 值分屬不同欄位者即屬混用維度，應拆分」。以下為我方建議，**待 BEAM PMO 裁定後執行**。

| # | 項目 | 建議 | 理由 |
|---|---|---|---|
| A | 商店型態 vs 業別 | **新增欄位 `MERCHANT_FORM`**（`VARCHAR2(24)`，CHECK `IN ('SINGLE','FRANCHISE','CHAIN','GROUP')`）；`MERCHANT_TYPE` **保留業別** | 兩軸正交，合併即重演 `CR-008`／`CR-012` 之覆轍；契約 `merchantType` **對映至 `MERCHANT_FORM`**（契約語意即「型態」），業別另以 `industryType` 補入契約 |
| B | `merchants.status` | **DDL 增列 `APPLIED`／`CLOSED`**（保留 `TERMINATED` 或改契約用 `CLOSED`——**建議契約統一為 `CLOSED`**，`TERMINATED` 保留於 DDL 供相容） | 「申請中」為真實流程（進件→核准→`ACTIVE`）；`CLOSED` 較 `TERMINATED` 中性且契約已用 |
| C | `settlements.status` | **DDL 增列 `SUBMITTED`／`VERIFIED`／`EXCEPTION`／`PAID`**，並**新增 `EXCEPTION_REASON VARCHAR2(240)`** | 契約粒度為請款流程，DDL 之 `PENDING`／`SETTLED` 不足以表達；`EXCEPTION` 需有原因欄（稽核） |
| D | `parentMerchantId` | **DDL 新增 `PARENT_MERCHANT_ID RAW(16)`**（自參考 FK，`NULL` 表非集團店） | 集團店為 `ACQ-4.1` 明確需求 |
| E | `acceptedCardTypes` | **DDL 新增 `ACCEPTED_CARD_TYPES` JSON**（比照 `TERMINAL_IDS` 之 JSON 陣列手法） | 已有同表同手法前例 |
| F | `batchNo` | **DDL 新增 `BATCH_NO VARCHAR2(32)`**（`BATCH_COUNT` 保留為筆數） | 語意不同，不可挪用 |
| G | 回應補欄 | `MerchantSettlementView` **補 `taxAmount`／`currency`／`settlementDate`**（改 `gen_openapi.py` 後重生） | 對帳單（`ACQ-4.3`）必要資訊 |
| H | 錯誤碼 | 沿用 `CC-MRC-1001`（商戶代碼重複，契約已載）；新增者先查 `SDD-006 §3.4` 既有清單再定 | 不硬編、不新增未登錄碼 |

**交付方式（若 A～G 核准）**：一律以 **`V2__merchant_form_and_status.sql`** 追加
（`V1` 基線已凍結，`SAD-003 §9`）；契約改 `tools/gen_openapi.py` 後重生；
`SCS-014` 升版並補 §5／§6／§7；`SDD-001 §5.9` 補 V2 交付說明。

---

## 4. 為何不「先實作再修」

契約與 DDL 之值域若未先對齊，實作將面臨兩難：
**寫 DDL 值** → 契約列舉形同虛設（客戶端送出 `SINGLE` 即 400，契約說可行）；
**寫契約值** → 觸發 `ORA-02290` 或需繞過約束（**假綠**）。
兩者皆屬客戶硬性要求所禁止（`scan_scaffold`／`scan_faketest` 之精神）。
故本 CR 先行，裁定後一次做對——**避免「一號開發團隊實作、二號開發團隊推翻」之 churn**。

---

## 5. 誠實揭露

- 本 CR 之 DDL 值域為 **Oracle 實測**（`CC_MCH`，2026-09-22），非文件抄錄。
- `CC_MCH` 兩表目前 **0 筆資料**，故無歷史資料需回填（遷移風險低）。
- `MERCHANTS.status` **未建模為 `SDD-005` 正式狀態機**（`SCS-014 §10` 已載）——
  建議本次一併決定是否補入（若補，須先改 DDL 再改 `SDD-005`，依 **SM-08**）。
- `SETTLEMENT_ACCOUNT_ENC`（加密帳號）之金鑰整合仍待基礎設施凍結
  （`SCS-014 §10`），**不在本 CR 範圍**；本期建檔不接收該欄位（避免半套加密）。

---

## 3.1 施行中追加之事項（我方依「替代方案推進」原則處理，供事後覆核）

| # | 事項 | 處理 | 理由 |
|---|---|---|---|
| I | `merchantId`（特店代碼）於請求中<b>可選</b> | 未提供 → 服務端以序號產生（`MERCH` ＋ 6 位）；提供 → 用之，重複則 `CC-MRC-1001` | 契約<b>描述</b>明載「代碼重複回 `CC-MRC-1001`」，惟<b>結構定義</b>無 `merchantId` 欄——若純由服務端產生，該錯誤碼**永不可達**（形同虛設）。加為可選欄使兩者並存且規則可測 |
| J | 請款金額<b>由呼叫方帶入</b>（`grossAmount`／`feeAmount`／`taxAmount`） | 契約補此三欄為必填 | 契約原僅有 `merchantId`／`settlementDate`／`batchNo`，**無任何金額欄位**；收單／清算資料源（`clearing-service`，`C-5`）未開發，服務端**無處**彙總（`NET = GROSS - FEE - TAX` 無從計算）。待 `C-5` 落地後改為服務端彙總 |
| K | <b>不</b>納入「商戶非 `ACTIVE` 不得請款」之檢核 | 異常判定<b>僅</b>保留「淨額為負」 | ①契約未定義「異常」清單；②本服務**無**狀態轉換端點（狀態機未建模），建檔後恆為 `APPLIED`——若加此守衛，「建檔 → 請款」之**正常路徑將永不可達**（與 `C-19` 之 `ALLOCATED` 同類陷阱）。是否納入待狀態機建模時一併裁定 |
| L | 契約所述「同鍵<b>異指紋</b>回 409」<b>未實作</b> | 僅實作「同鍵同主鍵」之冪等（不重複建檔） | `MERCHANTS`／`MERCHANT_SETTLEMENTS` **無**指紋欄位可存，且不宜為此挪用他欄（避免假綠） |

**冪等實作方式（無冪等鍵欄位）**：以冪等鍵**派生主鍵**——
`UUID.nameUUIDFromBytes("mch:merchant:" + key)`（同理 `mch:settlement:`）——
同鍵重送得同一主鍵，第二次即命中既有列並回傳原結果（不新增、不重抽序號）。
命名空間前綴使不同實體之同鍵不致碰撞。

---

## 4. 執行清單（2026-09-22）

| 項 | 交付 |
|---|---|
| 遷移 | `V2__merchant_form_and_status.sql`——`MERCHANT_FORM`／`PARENT_MERCHANT_ID`／`ACCEPTED_CARD_TYPES`（JSON）／`BATCH_NO`／`EXCEPTION_REASON` 增欄；`merchants.status` 與 `merchant_settlements.status` 之 CHECK **以資料字典動態尋得後重建為具名約束**（`CK_MERCHANTS_STATUS`／`CK_MERCHANT_SETTLEMENTS_STATUS`，比照 `V4__payments_unmatched_status.sql`）；新增 `merchant_id_seq`／`merchant_settlement_no_seq` |
| 契約 | 改 `tools/gen_openapi.py` 後重生（`CreateMerchantRequest` 補 `industryType` 與可選 `merchantId`；`MerchantView` 補 `industryType`、`status` 補 `APPLIED`／`TERMINATED`；`MerchantSettlementRequest` 補三金額欄；`MerchantSettlementView` 補 `taxAmount`／`currency`／`settlementDate`）。**驗證 0 錯誤、全案 74 端點** |
| 程式 | DDD 四層：`Merchant`（含 `MerchantForm`／`IndustryType`／`MerchantStatus`／`RiskLevel`）、`MerchantSettlement`（含 `SettlementStatus` 6 值）、2 埠＋2 JDBC、2 應用服務＋組裝器、1 控制器（3 端點） |
| 品質 | 單測 **27 例 0 失敗**（領域 12／應用 15；含冪等重放、代碼重複、上層查無、雙軸獨立、淨額為負異常）；`scan_scaffold`／`scan_faketest` 0 命中；`validate_sql` 0 違規 |

---

## 5. 真機 e2e（8095／`FREEPDB1`，2026-09-22）

| # | 情境 | 結果 |
|---|---|---|
| ① | 建檔（`merchantType=CHAIN`、`industryType=SUPERMARKET`，未指定代碼） | **201**：`MERCH000001`／`CHAIN`／`SUPERMARKET`／`APPLIED` |
| ② | 同冪等鍵重送 | 回**同一** `MERCH000001`（未新增、未重抽序號） |
| ③ | 指定已存在代碼 | **422 `CC-MRC-1001`**「特店代碼重複：MERCH000001」 |
| ④ | 查詢 `GET /merchants/MERCH000001` | **200**：名稱／雙軸／`LOW`／`APPLIED` 全對 |
| ⑤ | 請款 `500000 − 12500 − 0` | **201**：`SET202609000001`、`net=487500`、`TWD`、`2026-09-17`、`SUBMITTED` |
| ⑥ | 請款 `1000 − 800 − 300` | **201**：`net=−100`、**`EXCEPTION`**、原因「淨額為負（費用＋稅大於請款總額）…」 |
| ⑦ | **DB 複核** | `MERCHANTS`：`MERCHANT_FORM=CHAIN`、`MERCHANT_TYPE=SUPERMARKET`（**同列兩軸獨立**）、`ACCEPTED_CARD_TYPES=["VISA","MASTERCARD"]`、`PARENT_MERCHANT_ID=NULL`；`MERCHANT_SETTLEMENTS`：兩列金額與狀態逐欄相符；V2 具名約束存在且值域已擴充 |

---

## 6. 修訂記錄

| 版本 | 日期 | 修訂內容 |
|---|---|---|
| 1.0 | 2026-09-22 | 提出。動工前查證發現 3 處值域脫鉤＋3 項契約欄位無處可存＋1 處回應缺欄；核心判讀為「商店型態與業別為兩個正交維度」，建議採**雙欄位**（比照 `CR-008`／`CR-012`）。待 BEAM PMO 裁定 |
| 1.1 | 2026-09-22 | **BEAM PMO 裁定：A 採雙欄位、B～G 一次核准**。新增 **§3.1 施行中追加事項**（I 代碼可選／J 金額 stand-in／K 不納入商戶狀態檢核／L 異指紋未實作，均附理由）、**§4 執行清單**、**§5 真機 e2e 七項**（含 DB 複核雙軸獨立）。單測 27 例 0 失敗；`SCS-014` 升 1.1、`SDD-001` 升 1.8 |
