# CR-019　契約 ↔ 實作「逐端點核對」之檢查點建置，與 **19 處不符**之登記與分流

| 項目 | 內容 |
|---|---|
| 文件編號 | CR-019 |
| 版本 | **1.2（**§3.8：批 1 之 15 案已執行並真機驗證**——19 處 → **2 處**；§3.6.6 之批 2（C-1）與新增 C-4 待裁定）** |
| 提出日 | 2026-09-23 |
| 提出者 | WorkBuddy AI（開發團隊） |
| 緣起 | `GOV-006 §4 #14`——`CR-016 §8` 與 `CR-018` 於**同一日、同一次** `C-14` 動工前查證中先後被發現，顯示「**契約 vs 實作之逐端點核對**」尚未形成例行檢查點 |
| 影響文件 | 新增 `tools/verify_contract_dto.py`；`CSP-001`（品質關卡）；各服務契約（`tools/gen_openapi.py`）與 DTO |
| 關聯前例 | `CR-016 §8`（回應型別：實作回陣列、契約誤宣告單一物件）、`CR-018`（實作有欄位、契約無） |

---

## 1. 為什麼需要（問題定性）

`CR-016 §8` 與 `CR-018` 屬**同一類缺陷**，且其特性使它們**極難被既有關卡攔下**：

| 特性 | 說明 |
|---|---|
| **編譯不會錯** | 契約是 YAML、DTO 是 Java，兩者無編譯期關聯 |
| **啟動不會錯** | 契約不參與執行期繫結 |
| **單測不會錯** | 單測驗的是實作行為，不驗「契約是否描述實作」 |
| **只有人逐欄看才會發現** | 故缺陷會**累積**，直到有消費端（如 WebUI）依契約實作才爆 |

→ 結論：**必須把它變成可重複執行的關卡**，不能依賴人的記憶。

---

## 2. 交付：`tools/verify_contract_dto.py`（零相依）

| 項 | 內容 |
|---|---|
| 檢查項 | **A. 回應型別**：契約該端點成功回應之 `data`（陣列？元素 schema）vs Controller 回傳型別<br>**B. 欄位集合**：契約 `components.schemas.<Name>.properties` vs Java `record <Name>(…)` 元件（**雙向**） |
| 設計 | **零相依**（本環境**無 PyYAML**，已實測）→ 自製最小 YAML 子集解析器（格式由 `gen_openapi.py` 之 `yaml_dump` 固定產生）<br>**解析失敗一律拋錯**，不靜默略過（靜默＝看起來有把關、實則沒擋＝假綠） |
| 判定分流 | `hits`＝**確定不符**（陣列性不符／欄位不符）→ 須修<br>`warns`＝**需人工確認**（型別**名**不符但欄位一致＝僅命名差異；JSON 之契約效力在**欄位名**而非類別名）<br>`infos`＝無對應 DTO／未找到端點，屬正常或無法判定 |
| 人工樣本回測 | `--selftest`：4 案例（**陣列性不符**／DTO 有契約無／契約有 DTO 無／**對齊無缺陷**）→ **4/4 通過** |
| 用法 | `python3 tools/verify_contract_dto.py`（全庫）／`--service <name>`／`--selftest`／`--show-info` |

### 2.1 建置過程中修正之**自身缺陷**（誠實揭露，皆為「差點產生偽陽性」）

| # | 缺陷 | 症狀 | 修正 |
|---|---|---|---|
| a | 最小 YAML 解析器之終止條件誤用 `paths:` | 產生之契約 **`paths:` 在 `components:` 之前** → 一進檔案就 break，**全部服務誤報「解析失敗」** | 改為「先進入 `components/schemas` 區才開始收」 |
| b | `components:` 之下 **`parameters:` 先於 `schemas:`** | 看到 `parameters:` 即把 `in_schemas` 關掉後**再也進不來** | 旗標拆為 `in_components`／`in_schemas`，先判 `ind==2` 再判 `in_schemas` |
| c | 非貪婪 `(.*?)>` 解析 `ApiResponse<List<X>>` | 在第一個 `>` 就停 → 得到 `List<X`，**元素型別被截斷** → 12 處偽陽性 | 改**深度計數**取最外層泛型內容 |
| d | 未解析**具名陣列 schema** | `AuthorizationSummaryList` 之 `type: array` + `items: $ref X` → 誤判「契約單一物件 vs 實作陣列」 | `parse_contract_schemas` 一併解析 `type`／`items`，端點側做 `resolve()` |
| e | `resolve()` 覆蓋端點**內聯陣列**之判定 | 端點已偵測為 `type: array`，卻被「元素 schema 非陣列」覆蓋 → clearing／statement／pricing 偽陽性 | 兩者取 **OR**（兩種寫法都要認） |
| f | 只掃 `application/dto/` | DTO 常為 Controller／Service 之**巢狀 record**（`CardStatusView` 在 `CardQueryService`、`SpecialLimitsResponse` 在 `LimitAdjustmentController`）→ 產生「無法比對欄位」之無效警告 | 改**遞迴掃描整個 `src/main/java`** |
| g | 命中訊息中之 `**` 粗體切開關鍵詞 | 回測斷言比對不到（`陣列性不符` 被切成 `陣列性**不符`） | 訊息用詞去除行內粗體 |

> **⚠️ 教訓（比缺陷本身重要）**：本工具**首輪產出 12 處偽陽性**——若不逐一鑑別就當成 12 個缺陷回報，
> 將嚴重損害可信度。**掃描器之「判定力」必須以人工樣本回測，且真實輸出須逐筆鑑別**，
> 不可直接採信（此即 `HANDOFF` 所載「別輕信掃描器皆 0 命中」之**對偶原則**）。

---

## 3. 掃描結果：**19 處不符**（2026-09-23，17 服務）

> 命令：`python3 tools/verify_contract_dto.py`（回測 4/4 通過後執行）

### 3.1 分類 A：**回應陣列性不符**（契約單一物件、實作回集合）— 1 處

| 服務 | 端點 | 契約 | 實作 | 判定 |
|---|---|---|---|---|
| `posting` | `GET /api/v1/accounts/{accountId}/transactions` | `$ref TransactionView`（單一） | `List<TransactionView>`（`BalanceQueryController`） | **契約缺陷**（與 `CR-016 §8` 同型；語意為「分頁查詢交易」＝清單） |

### 3.2 分類 B：**回應型別名不符且欄位不符** — 6 處

| 服務 | 端點 | 契約宣告 | 實作回 | 契約有而實作無 | 實作有而契約無 |
|---|---|---|---|---|---|
| `card` | `GET /cards/{cardId}` | `CardView` | `CardStatusView` | `partyId`／`productCode`／`bin`／`instantIssued`／`tokens`／`createdAt` | `transactable`／`expired` |
| `card` | `POST /cards/{cardId}/lifecycle-transitions` | `CardView` | `CardStatusView` | 同上 | 同上 |
| `card` | `POST /card-applications` | `CardApplicationView` | `ApplicationResult` | —（欄位齊） | `replayed` |
| `card` | `GET /card-applications/{applicationId}` | `CardApplicationView` | `ApplicationResult` | — | `replayed` |
| `card` | `POST /card-applications/{applicationId}/transitions` | `CardApplicationView` | `ApplicationResult` | — | `replayed` |
| `rewards` | `POST /reward-rules` | **`RedemptionView`**（兌換視圖！） | `RewardRuleView` | 12 欄（`redemptionId`…`cashValue`…） | 7 欄（`ruleId`…`effectiveTo`） |

> **`rewards POST /reward-rules` 為最明確之契約缺陷**：端點為「設定回饋規則」，
> 契約卻宣告**兌換**視圖——兩者欄位**完全不相交**。此為 `gen_openapi.py` 之端點宣告誤引 schema。

### 3.3 分類 C：**欄位集合不符**（同名 schema ↔ 同名 DTO）— 12 處

| 服務 | schema | 契約有、DTO 無 | DTO 有、契約無 |
|---|---|---|---|
| `authorization` | `AuthorizeRequest` | `merchantName`／`entryMode`／`emvData` | — |
| `card` | `CardView` | `createdAt` | — |
| `card` | `TransitionRequest` | — | `operatorId` |
| `installment` | `InstallmentPeriod` | — | `principalAmount` |
| `limit` | `HoldRequest` | — | `currency` |
| `limit` | `HoldView` | `availableAmount` | `authorizationId`／`currency`／`createdAt` |
| `limit` | `LimitView` | `specialLimits` | `holdAmount` |
| `limit` | `ReleaseRequest` | `releaseAmount`／`reason` | `amount`／`currency` |
| `posting` | `SubAccountEntry` | — | `currency` |

> ⚠️ **分類 C 須逐案判定方向**（三種可能）：
> ①**契約過時**（實作已改、契約未同步）→ 改契約；
> ②**實作漏做**（契約有、實作未接）→ 改實作；
> ③**刻意不對外**（如內部欄位）→ 應於契約加註或自 DTO 分離，**不可留白**。
> 例：`limit ReleaseRequest` 之 `releaseAmount`/`reason` vs `amount`/`currency` 屬**命名不一致**，
> 消費端依契約送 `releaseAmount` 將**不被接收**——**極可能是真缺陷**。

### 3.4 需人工確認（1 則，非缺陷）

| 服務 | 端點 | 說明 |
|---|---|---|
| `limit` | `GET /accounts/{accountId}/special-limits` | 契約 `SpecialLimitsView` vs 實作 `SpecialLimitsResponse`——**欄位完全一致**，僅類別命名差異（JSON 契約效力在欄位名，故**不影響消費端**；惟建議對齊命名以免誤導） |

### 3.5 ⚠️ **分類 D：同名字段之語意不一致**（**本工具無法偵測**，於 `C-14` 批次二實測中發現）— 1 處

> **發現途徑**：`C-14` 批次二之商店管理頁實測（非本工具）——建檔後畫面顯示特店代碼 `MERCH000002`，
> 但**商店請款**成功後，回應之 `merchantId` 卻是 **UUID** `c9f1a464-…`。

| 服務 | 欄位 | 契約 | 實作 | 語意 |
|---|---|---|---|---|
| `merchant` | `MerchantView.merchantId` | `string`（未指明格式） | `MERCHANTS.MERCHANT_ID` | **業務代碼**（`MERCH000002`） |
| `merchant` | `MerchantSettlementRequest.merchantId` | `string`（未指明格式） | `findByMerchantId(...)` 查 `MERCHANTS.MERCHANT_ID` | **業務代碼**（一致 ✓） |
| `merchant` | `MerchantSettlementView.merchantId` | `string`（未指明格式） | `MERCHANT_SETTLEMENTS.MERCHANT_ID` ← `merchant.id()` | ⚠️ **UUID（RAW(16) 之代理主鍵）** |

**實測證據**（2026-09-23）：

| 證據 | 值 |
|---|---|
| 畫面（建檔結果） | 特店代碼 **`MERCH000002`** |
| 畫面（請款結果） | 特店代碼 **`c9f1a464-097e-3c15-824b-958820b17870`** |
| DB `MERCHANT_SETTLEMENTS.MERCHANT_ID` | `C9F1A464097E3C15824B958820B17870`（＝`MERCHANTS.ID`） |
| 程式 | `SettlementService`：`findByMerchantId(request.merchantId())` → `settlement.submit(..., merchant.id(), ...)` |

**影響**：消費端若做**往返**（讀請款結果之 `merchantId` → 回頭查 `GET /merchants/{merchantId}`）將 **404**，
因為前者是 UUID、後者要業務代碼。**同一欄位名在兩張視圖中代表不同識別碼**，
且契約**未以 `format`／`pattern` 區分**——契約本身亦未盡描述之責。

**建議修正方向（待裁定）**：三選一——
**(a)** 回應改回**業務代碼**（與 `MerchantView` 一致；最小驚訝原則）；
**(b)** 保留 UUID 但**契約加 `format: uuid`**，並另加 `merchantCode` 欄位回傳業務代碼；
**(c)** 兩者皆回（`merchantId`＝UUID、`merchantCode`＝業務代碼），契約明載。
**建議 (a)**——本案其他服務之「對外識別碼」慣例為業務代碼（如 `DSP202609000002`／`SET202609000003`）。

> **⚠️ 本工具之能力邊界（誠實揭露）**：分類 D 屬「**欄位名相同、型別相同、語意不同**」，
> 本工具之比對維度為**名稱與集合**，**結構上無法偵測**。要涵蓋此類需引入
> **語意標註**（如契約 `format`／`pattern`／`description` 與 DTO 之 `@Schema` 對照）或**人工複核**——
> 列後續（見 §7 限制 5）。

---

### 3.6 ★ 逐案判定表（**供一次核定**；契約與 DTO 原文對照＋預判方向＋影響面）

> **本表用途**：BEAM PMO 裁定「先出逐案判定表再一次核定，避免逐筆來回」。
> **19 處 hits ＋ 1 則 warn ＝ 20 案**，逐案列出：**位置｜契約原文｜DTO 原文｜差異｜預判方向｜影響面**。
> 方向代號：**（改契）**＝改 `tools/gen_openapi.py` 後重生；**（改實）**＝改實作；**（待決）**＝需您定方向。

#### 3.6.1 分類 A：回應陣列性（1 案）

| 案 | 位置 | 契約原文 | 實作原文 | 預判方向 | 影響面 |
|---|---|---|---|---|---|
| **A-1** | `posting` `GET /accounts/{accountId}/transactions` | `data: $ref TransactionView`（**單一物件**） | `ApiResponse<List<TransactionView>>`（`BalanceQueryController`） | **（改契）**→ `TransactionView[]` | 消費端依契約視為單一物件 → **型別錯誤、取不到清單**。端點語意為「分頁查詢交易」＝清單（與 `CR-016 §8` 同型，方向無疑義） |

#### 3.6.2 分類 B：回應型別名與欄位不符（6 案）

| 案 | 位置 | 契約宣告 | 實作回 | 差異 | 預判方向 | 影響面 |
|---|---|---|---|---|---|---|
| **B-1** | `card` `GET /cards/{cardId}` | `CardView`（13 欄：`cardId`,`accountId`,`partyId`,`cardNoMasked`,`productCode`,`bin`,`expiryDate`,`blockCode`,`status`,`instantIssued`,`ticketType`,`tokens`,`createdAt`） | `CardStatusView`（9 欄：`cardId`,`accountId`,`cardNoMasked`,`blockCode`,`status`,`expiryDate`,`ticketType`,`transactable`,`expired`） | 契約有而實作無 6 欄（`partyId`,`productCode`,`bin`,`instantIssued`,`tokens`,`createdAt`）；實作有而契約無 2 欄（`transactable`,`expired`） | **（改契）**→ 指 `CardStatusView` | 消費端預期 13 欄、實得 9 欄 → **`partyId`／`productCode` 等取不到**。⚠️ **`CardStatusView` 契約早已宣告**（供 `GET /cards/lookup/{cardNoHash}` 用）→ 實作與該端點一致，**契約未同步** |
| **B-2** | `card` `POST /cards/{cardId}/lifecycle-transitions` | 同上 `CardView` | 同上 `CardStatusView` | 同上 | **（改契）**→ 指 `CardStatusView` | 同上（狀態轉換後回狀態視圖合理） |
| **B-3** | `card` `POST /card-applications` | `CardApplicationView`（11 欄） | `ApplicationResult`（12 欄＝11 欄 ＋ **`replayed`**） | 實作多 `replayed` | **（改契）**→ 新增 `ApplicationResult` schema（含 `replayed`） | 消費端**無從得知是否為冪等重播**（`replayed` 未宣告）；反之契約宣告之欄位實作全有 → 低風險但應對齊 |
| **B-4** | `card` `GET /card-applications/{applicationId}` | 同上 | 同上 | 同上 | **（改契）**；⚠️ 惟 `GET` 之 `replayed` 語意存疑 → 建議**保留**（恆 `false`）以簡化，或**拆分**兩型別 | 同上 |
| **B-5** | `card` `POST /card-applications/{applicationId}/transitions` | 同上 | 同上 | 同上 | **（改契）** | 同上 |
| **B-6** | `rewards` `POST /reward-rules` | **`RedemptionView`**（13 欄：`redemptionId`,`redemptionNo`,`redemptionType`,`settlementMethod`,`status`,`pointsUsed`,`amountPaid`,`cashValue`,`pointsBefore`,`pointsAfter`,`merchantShare`,`authorizationId`,`redeemedAt`） | `RewardRuleView`（7 欄：`ruleId`,`ruleCode`,`ruleType`,`status`,`priority`,`effectiveFrom`,`effectiveTo`） | **欄位完全不相交**（僅 `status` 同名） | **（改契）**→ 新增 `RewardRuleView` schema 並指之 | **最嚴重**：端點為「設定回饋規則」，契約卻宣告**兌換**視圖。消費端／前端**完全無法依契約解析**；且 `RewardRuleView` **未在契約中宣告** → 前端無型別可依 |

#### 3.6.3 分類 C：欄位集合（12 案）

| 案 | 位置（schema） | 契約原文 | DTO 原文 | 預判方向 | 影響面 |
|---|---|---|---|---|---|
| **C-1** | `authorization` `AuthorizeRequest` | 11 欄（含 **`merchantName`**,**`entryMode`**,**`emvData`**） | 8 欄（`stan`,`cardNo`,`txType`,`amount`,`currency`,`mcc`,`merchantId`,`txTime`） | **（待決）** | ⚠️ 三欄實作**未接收**。Spring Boot 預設**忽略未知欄位** → 消費端送 `emvData` **被靜默丟棄**（客戶以為送了 EMV 資料）——**「靜默不做」型風險**。且 `emvData`／`entryMode` 屬 13 大項檢核之**已知未實作**（`SCS-001 §10` L-1）。**建議**：**契約加註「本期未實作」**（誠實揭露）＋ `SCS-001 §10` 記明；**不建議**現在補實作（需 EMV 資料來源） |
| **C-2** | `card` `CardView` | 13 欄（含 **`createdAt`**） | 12 欄 | **（改契）**移除 `createdAt` | 消費端依契約取 `createdAt` 得 `null`。實作之 `CardView.of(IssueResult)` 未帶此欄；`cards.created_at` 存在但未回傳 → **移除為誠實**（若需，另立 CR 補實作） |
| **C-3** | `card` `TransitionRequest` | `event`,`reason`,`threeFactors` | ＋**`operatorId`** | **（改契）**補 `operatorId` | 消費端**無從得知可傳操作者**（稽核留痕；`SCS-024` 要求 operatorId）。屬契約漏列 |
| **C-4** | `installment` `InstallmentPeriod` | `periodNo`,`amount`,`feeAmount`,`dueDate`,`status`,`remainingPrincipal` | ＋**`principalAmount`** | **（改契）**補 `principalAmount` | **攤還表核心欄位**（每期本金）未宣告 → 前端無法呈現「本期本金」（`SDD-007 §5.4.8` 之攤還表要求此欄） |
| **C-5** | `limit` `HoldRequest` | `accountId`,`authorizationId`,`holdAmount`,`holdType` | ＋**`currency`** | **（改契）**補 `currency` | 消費端不知可指定幣別 → 多幣別佔用無法依契約表達 |
| **C-6** | `limit` `HoldView` | `holdId`,`accountId`,`holdAmount`,`releasedAmount`,`status`,**`availableAmount`** | ＋**`authorizationId`**,**`currency`**,**`createdAt`**（無 `availableAmount`） | **（改契）**補三欄；**`availableAmount` 建議（改契）移除** | ①消費端取 `availableAmount` 得 `null`（契約宣告不存在之欄位）；②實作已有之三欄未宣告（含 `authorizationId` 可回查授權）。⚠️ 移除 `availableAmount` 係因實作需額外查主檔；若您認為該欄必要，改為**（改實）補** |
| **C-7** | `limit` `LimitView` | 11 欄（含 **`specialLimits`**） | ＋**`holdAmount`**（無 `specialLimits`） | **（改契）**補 `holdAmount`；**`specialLimits` 建議（改契）移除** | ①消費端取 `specialLimits` 得 `null`；②`specialLimits` 已有**專用端點** `GET /accounts/{id}/special-limits` → 兩處來源易漂移，**移除為宜**（單一真實來源） |
| **C-8** | `limit` `ReleaseRequest` | **`releaseAmount`**,**`reason`** | **`amount`**,**`currency`** | **（改契）**→ `amount`＋`currency`；移除 `reason` | ⚠️ **消費端依契約送 `releaseAmount` 不會被綁定** → `amount` 為 0 → `@Min(1)` 擋下（**400**）。屬**真缺陷**。`reason` 移除係因 `LIMIT_HOLDS` **無該欄**（`SCS-002 §5`）且規格未要求；若需稽核原因，須另立 CR 加欄（V4 遷移） |
| **C-9** | `posting` `SubAccountEntry` | `subAccount`,`amount`,`beforeAmount`,`afterAmount` | ＋**`currency`** | **（改契）**補 `currency` | 分錄之幣別未宣告 → 消費端無法判斷分錄幣別（多幣別分戶時尤需） |

#### 3.6.4 分類 D：僅命名差異（1 案，非缺陷）

| 案 | 位置 | 契約 | 實作 | 預判方向 | 影響面 |
|---|---|---|---|---|---|
| **D-1** | `limit` `GET /accounts/{accountId}/special-limits` | `SpecialLimitsView`（`accountId`,`count`,`items`） | `SpecialLimitsResponse`（**欄位完全一致**） | **（改實）**將 record 改名為 `SpecialLimitsView` | **無**（JSON 契約效力在欄位名，類別名不影響消費端）；改名僅為一致性，**零風險**（不改 JSON）。⚠️ 此為我方 `C-11` 交付時之命名不一致 |

#### 3.6.5 分類 E：同名字段語意不一致（1 案，**已裁定**）

| 案 | 位置 | 說明 | 方向 | 影響面 |
|---|---|---|---|---|
| **E-1** | `merchant` `merchantId` | `MerchantView.merchantId`＝**業務代碼**（`MERCH000002`）；`MerchantSettlementView.merchantId`＝**UUID**（`c9f1a464-…`） | ✅ **已裁定採 (a)**：回應改回**業務代碼** | 消費端往返（讀請款之 `merchantId` → 查商戶）現為 **404**；修正後可往返。**完整證據見 §3.5；執行結果見 §3.7** |

#### 3.6.6 建議之執行分批（供核定）

| 批 | 內容 | 案數 | 理由 |
|---|---|---:|---|
| **批 1（方向無疑義）** | A-1、B-1～B-6、C-2～C-9、D-1 | **16** | 皆為「契約過時／漏列」或「零風險命名對齊」，方向無設計空間 |
| **批 2（需您定方向）** | **C-1**（`AuthorizeRequest` 三欄：加註未實作 vs 補實作） | **1** | 涉及「是否揭露未實作」之取捨 |
| **批 3（已裁定）** | E-1（`merchantId` 語意） | **1** | 您已裁定 (a) |
| — | C-6 之 `availableAmount`／C-7 之 `specialLimits` | （含於批 1） | 建議**移除**；若您認為應**補實作**，請於核定時指示 |



### 3.7 分類 E（E-1）之**執行結果**（2026-09-23 完成，BEAM PMO 已裁定採 (a)）

**修正內容**

| # | 檔案 | 變更 |
|---|---|---|
| 1 | `MerchantViewAssembler.toSettlementView(settlement, merchantCode)` | 簽名加參數；`merchantId` 改回傳**傳入之業務代碼**（原為 `settlement.merchantId().toString()`＝UUID） |
| 2 | `MerchantSettlementService.submit` | ①**新建路徑**：直接用已載入之 `merchant.merchantId()`（業務代碼），**無須再查**；②**冪等重播路徑**：新增 `resolveMerchantCode(UUID)`——以 `merchantRepository.findById(UUID)` 回查業務代碼；⚠️ **查無即拋 `IllegalStateException`（fail-loud）**，不靜默回退為 UUID（否則缺陷再次潛伏） |
| 3 | `tools/gen_openapi.py` | `MerchantView`／`MerchantSettlementRequest`／`MerchantSettlementView` 之 `merchantId` 三處**補 `description` 明載語意**（「特店**業務代碼**（如 MERCH000002）——**非**資料庫主鍵 UUID…可安全往返」）→ 防再犯 |
| 4 | 契約重生 | 影響範圍嚴格驗證：`cp -r` 基準 → 重生 → `diff -rq` 全目錄比對 → **僅 `merchant-service.yaml` 變動**（3 行 description）；OpenAPI **0 錯誤** |

**設計依據（不是我方臆測）**：`MerchantRepository#findByMerchantId` 之 javadoc **早已明載**
「對外 API 之 `merchantId` 即**業務代碼**（如 `MERCH000001`），**非**資料庫主鍵」
——即本缺陷係**實作偏離自身已載明之設計意圖**，故 (a) 為**回歸設計**而非新決策。

**真機 e2e（8095，3 情境）**

| # | 情境 | 結果 |
|---|---|---|
| ① | 商店請款（**新冪等鍵**） | **201**／`SET202609000004`／**`merchantId: "MERCH000002"`**（修正前為 UUID） |
| ② | **同鍵重播**（測 `resolveMerchantCode` 路徑） | **201**／同 `settlementId`／**`merchantId: "MERCH000002"`** |
| ③ | **往返驗證**：以回應之 `merchantId` 回查 `GET /merchants/{merchantId}` | **200**（修正前為 **404**）——`{"merchantId":"MERCH000002","merchantName":"示範特店",…}` |

**DB 複核**（證明**未改寫資料**、僅於組裝時解析）

| 請款單 | `MERCHANT_ID`（DB） | 解析後代碼 |
|---|---|---|
| `SET202609000001` | `241442B3D98B3E56A64D317FC4E547B5`（UUID） | `MERCH000001` |
| `SET202609000002` | 同上 | `MERCH000001` |
| `SET202609000003` | `C9F1A464097E3C15824B958820B17870`（UUID） | `MERCH000002` |
| `SET202609000004`（本次） | 同上 | `MERCH000002` |

→ ①欄位**仍為 UUID**（**未動 DDL、未做資料遷移**）；②**既有 3 列**亦可解析出代碼 → **無回填需求**。

**品質**：`merchant-service` 單測**通過**（`BUILD SUCCESSFUL`）；契約 0 錯誤；`verify_contract_dto --service merchant-service` **0 不符**。

**殘留風險（誠實揭露）**：`resolveMerchantCode` 依賴 `MERCHANTS` 與 `MERCHANT_SETTLEMENTS` 之**參照完整性**，
惟 DDL **未建外鍵**（本專案跨表一律以應用層保證）。若日後出現孤兒請款單，將**拋錯而非靜默**——
此為刻意之 fail-loud，並已於方法 javadoc 載明。

---

### 3.8 批 1 之**執行結果**（2026-09-23 完成，15 案）

**執行範圍**：§3.6.6 之批 1 **16 案中，C-4 經查證後剔除**（見 §3.8.3），實際執行 **15 案**。

#### 3.8.1 變更清單

| 案 | 變更 | 類型 |
|---|---|---|
| A-1 | `posting` `GET /accounts/{accountId}/transactions` 回應 → **`TransactionView[]`** | 契約 |
| B-1／B-2 | `card` `GET /cards/{cardId}`／`POST …/lifecycle-transitions` 回應 → **`CardStatusView`** | 契約 |
| B-3～B-5 | `card` 申請案件 3 端點回應 → **新增 `ApplicationResult`**（＝ `CardApplicationView` ＋ `replayed`） | 契約（**新增 schema**） |
| B-6 | `rewards` `POST /reward-rules` 回應 → **新增 `RewardRuleView`**（原誤引 `RedemptionView`） | 契約（**新增 schema**） |
| C-2 | `card` `CardView` **移除 `createdAt`**（實作無此欄） | 契約 |
| C-3 | `card` `TransitionRequest` **補 `operatorId`** | 契約 |
| C-5 | `limit` `HoldRequest` **補 `currency`** | 契約 |
| C-6 | `limit` `HoldView` **補 `authorizationId`／`currency`／`createdAt`**、**移除 `availableAmount`** | 契約 |
| C-7 | `limit` `LimitView` **補 `holdAmount`**、**移除 `specialLimits`**（已有專用端點，單一真實來源） | 契約 |
| C-8 | `limit` `ReleaseRequest` → **`amount`＋`currency`**（原 `releaseAmount`／`reason`） | 契約 |
| C-9 | `posting` `SubAccountEntry` **補 `currency`** | 契約 |
| D-1 | `limit` record 改名 **`SpecialLimitsResponse` → `SpecialLimitsView`**（欄位一致，零風險） | **實作** |

**做法**：一律改 `tools/gen_openapi.py`（**不可手改 `contracts/openapi/*.yaml`**）→ 重生 → **以 `cp -r` ＋ `diff -rq` 證明影響範圍**（**僅 `posting`／`card`／`rewards`／`limit` 四檔**）→ 重跑比對器。

#### 3.8.2 驗證結果

| 項 | 結果 |
|---|---|
| **比對器** | **19 處 → 2 處**（剩餘恰為**排除之兩案**：C-1 待裁定、C-4 已剔除） |
| OpenAPI 驗證 | **0 錯誤／74 端點** |
| 影響範圍 | `diff -rq` 確認僅 4 檔（＋`merchant` 於 §3.7 已改） |
| `limit-service` 單測＋`bootJar` | **BUILD SUCCESSFUL** |
| **真機回歸實測（4 端點）** | ①`GET /accounts/{id}/transactions` → **200／`data` 為 list（3 筆）**，與新契約 `type: array` 相符<br>②`GET /cards/{cardId}` → **200**，回應欄位 `cardId,accountId,cardNoMasked,blockCode,status,expiryDate,ticketType,transactable,expired`——**含 `CardStatusView` 之 `transactable`／`expired`、不含 `CardView` 之 `partyId`／`productCode`**<br>③`POST /reward-rules` → **201**，欄位 **恰為** `ruleId,ruleCode,ruleType,status,priority,effectiveFrom,effectiveTo`（＝新契約 `RewardRuleView`）<br>④`GET /accounts/{id}/special-limits` → **200**，欄位 `accountId,count,items`（改名後正常） |

#### 3.8.3 ⚠️ C-4 之**判定方向更正**（**由批 1 剔除，改列待裁定**）

§3.6.3 原判 C-4（`installment InstallmentPeriod` 之 `principalAmount`）為「**改契約補**」。
**執行前查證發現該判定有誤**：

| 證據 | 內容 |
|---|---|
| **程式** | `InstallmentPlan` 產生期數：`new InstallmentPeriod(i, principal, principal, fee, …)`——**第 1、2 參數同為 `principal`** |
| **DDL** | `INSTALLMENT_PERIODS` 有 `amount` **與** `principal_amount` 兩欄（皆 `NOT NULL`），值相同 |
| **規格** | `SCS-009 §2`／`§7` 僅定義「**每期本金**／手續費／到期日／剩餘本金」——**無「應繳總額」之概念** |
| **契約** | `amount` 之描述明載「本期本金…手續費另見 `feeAmount`」 |

→ **`principalAmount` 係 `amount` 之重複欄位**（DDL 冗餘），**契約省略它是正確的**。
若照原判定「補進契約」，將使 API **出現兩個語意相同的欄位**（消費者混淆）。

**⚠️ 仍待裁定之設計問題**：`INSTALLMENT_PERIODS.AMOUNT` 之**設計意圖**究竟是
**(a) 本期本金**（＝現行實作與契約描述，則 `PRINCIPAL_AMOUNT` 為冗餘欄，建議 V3 清理）
還是 **(b) 本期應繳總額＝本金＋手續費**（則**現行實作與契約描述皆為缺陷**）？
→ **此非機械性修正，須設計確認**（已登錄 `GOV-006 §4 #17`）。

#### 3.8.4 ⚠️ 施行中自身事故（誠實揭露）

**事故**：以 `str.replace(old, new, 1)` 批次修補產生器時，**C-9 之錨點不唯一**——
`beforeAmount`／`afterAmount` 兩行連續之樣式**同時存在於 `limit-service` 之 `LimitAdjustmentView`**，
而 `replace(..., 1)` 取**第一處**，致 **`currency` 被加進 `LimitAdjustmentView`（錯）**、
`posting` 之 `SubAccountEntry`（對）**未加**。比對器隨即顯示 2 個異常命中（新增 `LimitAdjustmentView`、未消 `SubAccountEntry`）→ **逐檔 `diff` 後定位並更正**。

**教訓（已寫入 skill）**：**批次文字替換之錨點必須唯一**——
應先 `assert s.count(old) == 1`（或加入足以辨識之上下文行），**不可預設「看起來夠特別」**。
本專案多處 schema 有相同欄位序列（`beforeAmount`／`afterAmount`、`holdAmount`／`holdType` 等），風險高。

---

## 4. 執行清單

| # | 動作 | 狀態 |
|---|---|---|
| 1 | 建置 `tools/verify_contract_dto.py`（零相依） | ✅ |
| 2 | 人工樣本回測（4 案例） | ✅ **4/4 通過** |
| 3 | 全庫掃描並**逐筆鑑別**（剔除自身偽陽性 5 類） | ✅ 19 處確定、1 則需確認 |
| 4 | 本 CR 登記 | ✅ |
| 5 | `CSP-001 §7.1` 增列檢查點 G-8（含實作紀律三條） | ✅ |
| 6 | **19 處之逐案分流與修正** | 🟡 **批 1 之 15 案 ✅ 已執行**（見 §3.8；19 處 → **2 處**）；⬜ **批 2（C-1）待裁定**；⬜ **C-4 已剔除並改列待裁定**（見 §3.8.3） |
| 7 | **分類 D（§3.5）之發現與登記** | ✅ 已登記（於 `C-14` 批次二實測中人工發現，**非工具**） |
| 8 | **§3.6 逐案判定表** | ✅ 已產出（20 案，含契約與 DTO 原文對照＋預判方向＋影響面） |
| 9 | **E-1（`merchantId` 語意）之修正與驗證** | ✅ **已執行**（BEAM PMO 裁定採 (a)）——見 **§3.7** |

---

## 5. 檢查點之制度化

於 `CSP-001 §7.1`（Quality Gate）增列 **G-8：契約 ↔ 實作逐端點核對**——
**新增／修改端點或回應 DTO 時，須執行 `verify_contract_dto.py` 並使 `--service <name>` 為 0 不符**。
理由：本 CR §1 之四項特性（編譯／啟動／單測皆不報），唯有此關卡可攔。

---

## 6. 待核定（逐案判定表）

**已產出 §3.6 逐案判定表（20 案）**，供 BEAM PMO**一次核定**，避免逐筆來回。建議之執行分批：

| 批 | 內容 | 案數 | 理由 | 狀態 |
|---|---|---:|---|---|
| **批 1（方向無疑義）** | A-1、B-1～B-6、C-2～C-3、C-5～C-9、D-1 | **15** | 皆為「契約過時／漏列」或「零風險命名對齊」，**無設計空間** | ✅ **已執行**（§3.8；原列 16 案，**C-4 剔除**） |
| **批 2（需定方向）** | **C-1**（`AuthorizeRequest` 之 `merchantName`／`entryMode`／`emvData`） | **1** | 涉及「**加註未實作**（誠實揭露）vs **補實作**（需 EMV 資料來源）」之取捨 | ⬜ 待核定 |
| **批 3（已裁定並執行）** | E-1（`merchantId` 語意） | **1** | BEAM PMO 2026-09-23 裁定採 **(a)** | ✅ **已完成**（§3.7） |
| — | C-6 之 `availableAmount`／C-7 之 `specialLimits` | （含於批 1） | 已依建議**移除**（契約不宣告實作未提供之欄位；`specialLimits` 已有專用端點） | ✅ 已執行 |
| **批 4（新增，需設計確認）** | **C-4**（`INSTALLMENT_PERIODS.AMOUNT` 之設計意圖：(a) 本期本金 vs (b) 本期應繳總額） | **1** | 執行前查證發現原判定有誤（**`principalAmount` 係 `amount` 之重複欄位**）→ 已剔除；**屬設計問題非機械修正**（見 §3.8.3） | ⬜ **待裁定**（`GOV-006 §4 #17`） |

> **⚠️ 批 2（C-1）之風險特別說明**：Spring Boot 預設**忽略未知請求欄位**，
> 故消費端送 `emvData`／`entryMode`／`merchantName` 會被**靜默丟棄**——
> 客戶以為送了 EMV 資料，實際未檢核。此屬「**靜默不做**」型風險（`HANDOFF` 所載反模式）。
> **我方建議**：**契約加註「本期未實作」**（`description` 明載）＋ `SCS-001 §10` 記明，
> **不建議**現在補實作（EMV／entryMode 需資料來源，屬 13 大項檢核之既有未實作範圍 `L-1`）。

## 7. 誠實揭露

- 本 CR **僅建置工具與登記**，**未修改任何契約或實作**——因多數項目之修正方向涉及對外行為契約。
- 工具之**已知限制**：
  1. **不檢查請求端**（`requestBody` 之 schema 未與 Request DTO 比對）——本期僅做回應側與 schema 欄位；請求側列後續。
  2. **端點比對以「路徑＋動詞」為鍵**，若 Controller 之 `@RequestMapping` 寫法特殊（如多路徑、`params` 條件）可能配不上 → 列為 `INFO`，**不偽裝成通過**。
  3. **欄位比對只看名稱、不看型別／必填**（`required` vs `@NotNull`）——列後續。
  4. 無法判定 Java 端之 `@JsonInclude`／`@JsonProperty` 改名（本期專案未使用）。
  5. **無法偵測「同名字段之語意不一致」**（分類 D，見 §3.5）——比對維度為名稱與集合，
     名稱相同、型別相同但**語意不同**（如業務代碼 vs UUID）者**結構上測不到**。
     此類為 `C-14` 批次二**實測時人工發現**，證明「工具不可取代端到端實測」。
- 本 CR 之 19 處**尚未逐案鑑別真偽**（僅抽驗 1 處為真：`InstallmentPeriod.principalAmount`）；
  分類 C 之方向判定須看實作與契約原文，列 §6 之判定表作業。

---

## 8. 修訂記錄

| 版本 | 日期 | 修訂內容 |
|---|---|---|
| 1.2 | 2026-09-23 | **批 1 之 15 案執行完成並真機驗證（見 §3.8）**——依 BEAM PMO「請繼續」：①**契約修正 14 案**（A-1／B-1～B-6／C-2／C-3／C-5～C-9）＋**實作 1 案**（D-1 record 改名）；一律改 `gen_openapi.py` → 重生 → `cp -r` ＋ `diff -rq` 證明影響範圍（**僅 4 檔**）。**比對器 19 處 → 2 處**（剩餘恰為排除之 C-1／C-4）；OpenAPI **0 錯誤**；`limit-service` 單測通過；**真機回歸實測 4 端點**（transactions 為陣列／`CardStatusView` 欄位／`RewardRuleView` 欄位／`SpecialLimitsView` 改名後正常）。⚠️ **併更正 C-4 之判定**——查證發現 `InstallmentPeriod.principalAmount` 係 `amount` 之**重複欄位**（DDL 冗餘），契約省略正確 → **由批 1 剔除**，改列「需設計確認」之待裁定（`GOV-006 §4 #17`）。⚠️ **併揭露自身事故**：批次替換之**錨點不唯一**致 C-9 之 `currency` 誤植 `LimitAdjustmentView`（§3.8.4）——已修正並將「錨點須唯一」寫入 skill |
| 1.1 | 2026-09-23 | **新增 §3.6 逐案判定表（20 案，供一次核定）**——依 BEAM PMO 指示「先出逐案判定表再一次核定，避免逐筆來回」：逐案列**契約原文／DTO 原文／差異／預判方向／影響面**，並分為**批 1（16 案，方向無疑義）／批 2（1 案，C-1 待定）／批 3（1 案已裁定）**。**併完成 §3.7：E-1（`merchantId` 語意）之修正與真機驗證**——BEAM PMO 裁定採 **(a) 回應改回業務代碼**；改 `MerchantViewAssembler`／`MerchantSettlementService`（新增 `resolveMerchantCode`，**查無即拋** fail-loud）＋ `gen_openapi.py` 三處補 `description` 明載語意；真機 e2e **3 情境**（新鍵請款／同鍵重播／**往返回查 200**，修正前 404）＋ DB 複核（**未動 DDL、未做資料遷移**）。**設計依據**：`MerchantRepository#findByMerchantId` 之 javadoc 早已明載「對外 `merchantId` 即業務代碼」→ 本缺陷係**實作偏離自身設計意圖**，故 (a) 為**回歸設計**而非新決策 |
| 1.0 | 2026-09-23 | 提出並建置。`GOV-006 §4 #14` 之執行：新增 `tools/verify_contract_dto.py`（零相依、`--selftest` **4/4**）；建置中修正**自身 7 項缺陷**（5 類偽陽性）；全庫掃描得 **19 處不符**（A 陣列性 1／B 型別與欄位 6／C 欄位集合 12）＋ 1 則僅命名差異；`CSP-001` → **1.9**（§7.1 增列 **G-8** 檢查點）。**19 處之修正待分流裁定**。⚠️ **併新增 §3.5 分類 D**：於 `C-14` 批次二實測中發現
「`merchant` 之 `merchantId` 同名字段語意不一致」（請求＝業務代碼、`MerchantSettlementView` 回應＝UUID）
——**本工具結構上測不到**（名稱／型別皆相同），已列為限制 5 |
