# CR-058　企業卡改綁、企業額度申請→覆核、Stand-in 上傳載體——**設計凍結**

| 項目 | 內容 |
|---|---|
| 文件編號 | CR-058 |
| 版本 | **1.1** |
| 狀態 | **已實作並真機驗證**（C／D／E 全落地；見 §8） |
| 觸發 | BEAM PMO 2026-10-18：「企業卡改綁流程、企業額度申請→覆核接線。<- 請您設計！Stand-in 上傳 instrumentId。<- 請您設計！」 |
| 需求依據 | `CR-055 §11／§12`（企業子額度）、`CR-054 P1／P2`（載體維度）、`CR-052 §4.1`（投影僅供查詢、授權走即時） |
| 影響模組 | `limit-service`（改綁、企業覆核）、`authorization-service`（Stand-in 上傳） |
| 關聯 | `CR-055`／`CR-054`／`CR-057`／`#A-296`／`#A-297` |

---

## 1. 三項缺口（先講結論）

| # | 缺口 | 現況 | 風險 | 本 CR 決策 |
|---|---|---|---|---|
| **C** | 企業卡**改綁** | `bindInstrument` 對「已綁其他單位」直接拒絕（`CC-LIM-1003`），**無正式改綁流程** | 組織異動時只能人工改 DB（無稽核）或卡死 | **新增顯式改綁**＋歷史表；既有佔用 **forward-only 不搬移** |
| **D** | 企業額度**申請→覆核** | `PUT /corp-units/{id}/limit` 為**風控直接核定**；`LimitAdjustmentService` 只認 `instrumentId`→`CARD`，否則 `ACCOUNT` | 企業額度無申請軌跡、無門檻、無覆核 | **新增 `CORP_SUB` 申請路徑**走同一狀態機；`PUT` 保留為風控覆寫但**留審計** |
| **E** | Stand-in 上傳**載體** | `PendingStandIn`／`LimitUploadPort` **不帶 `instrumentId`／`holdType`**（本地表 `limit_holds.instrument_id` 已由 `V14` 建好） | 補送後佔用無載體 → 卡片級可用取下界、`holdAttribution=PARTIAL`；預借代佔也不會帶 `CASH_ADVANCE` | **上送帶 `instrumentId`＋`holdType`**；舊資料 `null` 照舊、不臆造 |

> 三項皆**不改變**既有語意：C 不改寫歷史佔用、D 不改 `PUT` 的風控地位、E 不強制舊資料補值。

---

## 2. 企業卡改綁（C）

### 2.1 現況與為何當初禁止

`corp_unit_instruments` 以 `instrument_id` 為 PK（`V19`），`bindInstrument` 對已存在且單位不同者拋 `CC-LIM-1003`。當初禁止的理由是**避免靜默改綁**——組織異動必須留痕。但「完全沒有改綁路徑」會迫使維運直接改 DB，反而更危險。

### 2.2 語意決策：**forward-only（既有佔用不搬移）**

```mermaid
flowchart TD
  A["改綁請求 unitId + instrumentId + reason"] --> B{"instrument 已綁定？"}
  B -->|"否"| C["拒絕 CC-LIM-1003：請先使用 bind"]
  B -->|"是"| D{"目標單位 = 現有單位？"}
  D -->|"是"| E["冪等回 200，不寫歷史"]
  D -->|"否"| F["更新 corp_unit_instruments"]
  F --> G["寫入 corp_unit_instrument_history"]
  G --> H["既有未釋放佔用保留原單位"]
  H --> I["回傳 openHoldsAtOldUnit 供維運判斷"]
```

| 決策 | 內容 | 理由 |
|---|---|---|
| 既有未釋放佔用 | **保留原 `corp_unit_id`**，不搬移 | 佔用是「**發生當時**的歸屬」；搬移會**回溯改變**上層可用與稽核軌跡，等於篡改歷史 |
| 新佔用 | 一律歸**新單位** | 改綁後的行為應立即反映新歸屬 |
| 舊單位可用何時恢復 | 舊佔用**釋放／到期**後自然恢復 | 與「佔用扣減上層」一致，無需特例 |
| 是否要求先清空舊佔用 | **不要求**，但回傳 `openHoldsAtOldUnit` 提醒 | 強制清空會讓改綁卡在「等所有交易到期」，實務不可行 |

> ★ 一句話：**改綁改的是「未來歸屬」，不是「過去帳」**。

### 2.3 資料模型（`V20`）

```sql
CREATE TABLE corp_unit_instrument_history (
  id            RAW(16) PRIMARY KEY,
  instrument_id RAW(16) NOT NULL,
  from_unit_id  RAW(16),                 -- NULL = 首次綁定
  to_unit_id    RAW(16) NOT NULL,
  reason        VARCHAR2(200 CHAR) NOT NULL,
  changed_by    VARCHAR2(64 CHAR) NOT NULL,
  changed_at    TIMESTAMP(6) WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
  created_at    TIMESTAMP(6) WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
  updated_at    TIMESTAMP(6) WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
  version       NUMBER(10) DEFAULT 0 NOT NULL
);
CREATE INDEX ix_cuih_instrument ON corp_unit_instrument_history (instrument_id, changed_at);
```

- 只**新增**歷史表，不改 `corp_unit_instruments`（PK 仍保證一卡一單位）。
- `from_unit_id` 可空：保留「首次綁定」的稽核（若未來 `bind` 也寫歷史）。

### 2.4 API

`POST /api/v1/corp-units/{unitId}/instruments/{instrumentId}/rebind`

| 欄位 | 必填 | 說明 |
|---|---|---|
| `reason` | ✔ | 改綁原因（稽核用） |
| `changedBy` | ✘ | **優先取自權杖**；未取得才用請求值（不得由前端冒充） |

回應：`unitId`、`instrumentId`、`fromUnitId`、`toUnitId`、`openHoldsAtOldUnit`、`changedAt`。

| 情境 | 行為 |
|---|---|
| 目標單位不存在／停用 | `CC-LIM-1003` |
| instrument 尚未綁定 | `CC-LIM-1003`（訊息導引先用 `bind`） |
| 目標＝現有單位 | **冪等** 200，不寫歷史 |
| 目標≠現有單位 | 更新＋寫歷史＋回傳舊單位未釋放佔用數 |

### 2.5 錯誤碼與權限

| 碼 | 語意 |
|---|---|
| `CC-LIM-1003` | 單位不存在／停用、instrument 未綁定 |
| `CC-LIM-1001` | 資料異常（如歷史寫入失敗） |

權限：需 `corp:rebind`（設計建議）；**不得**由一般客服角色執行。

### 2.6 稽核與可觀測

- 每筆改綁一列歷史（不可更新、不可刪）。
- 結構化日誌：`instrumentId`／`fromUnit`／`toUnit`／`operator`／`openHolds`。
- 指標：`corp_rebind_total`（計數）、`corp_rebind_open_holds`（Gauge）。

### 2.7 明確不做

- 不改寫既有 `limit_holds.corp_unit_id`；不做批次改綁；不做「偵測到組織異動自動改綁」。

---

## 3. 企業額度申請→覆核（D）

### 3.1 現況

- `PUT /corp-units/{id}/limit` 直接寫 `CORP_SUB` 額度，**無申請單、無門檻、無覆核**（`CR-055 §11.5` 已揭露）。
- `limit_adjustments` **已支援** `subject_type='CORP_SUB'`（`V12` 之 CHECK 值域），只差**寫入路徑**。
- `LimitAdjustmentService.apply` 目前以「有無 `instrumentId`」判定 `CARD`／`ACCOUNT`，**無法表達 `CORP_SUB`**。

### 3.2 語意決策：申請走狀態機，`PUT` 保留為風控覆寫（但留審計）

| 路徑 | 用途 | 是否走狀態機 | 是否留 `limit_adjustments` |
|---|---|---|---|
| `POST /corp-units/{unitId}/limit-adjustments` | **客戶／業務申請** | ✔（`DRAFT`→`PENDING_APPROVAL`／`EFFECTIVE`） | ✔ |
| `POST /limit-adjustments/{id}/approval` | **覆核**（既有端點） | ✔ | ✔ |
| `PUT /corp-units/{unitId}/limit` | **風控緊急覆寫** | ✘（直接生效） | ✔ **仍寫一筆 `EFFECTIVE` 軌跡**（見 §3.6） |

> ★ 兩者共用同一張表與同一套「維度＋主體」語意，差別只在**是否經門檻與覆核**；避免「申請一套、核定另一套」的兩個真相。

### 3.3 主體與歸屬

| 欄位 | 值 |
|---|---|
| `subject_type` | `CORP_SUB` |
| `subject_id` | `corp_units.id` |
| `account_id` | `corp_units.settlement_account_id`（由單位解析，**不臆造**） |
| `adjust_type` | `PERMANENT`（企業總額度；`TEMPORARY`／`SHADOW` 待客戶口徑） |

### 3.4 資料模型

**無新表**：`limit_adjustments` 已有 `subject_type`／`subject_id`（`V12`），且 CHECK 已含 `CORP_SUB`。實作時確認 `JdbcLimitAdjustmentRepository` 之 INSERT 有寫入這兩欄（CARD 已如此，CORP_SUB 沿用）。

### 3.5 API

`POST /api/v1/corp-units/{unitId}/limit-adjustments`

| 欄位 | 必填 | 說明 |
|---|---|---|
| `targetAmount` | ✔ | 調整後**目標總額度**（分） |
| `currency` | ✔ | 幣別 |
| `reason` | ✔ | 申請原因 |
| `applicantId` | ✔ | 申請人 |

回應：既有 `LimitAdjustmentView`（`subjectType=CORP_SUB`、`instrumentId=null`、`subjectId` 另以欄位回傳或由 `unitId` 對照）。

### 3.6 狀態機與門檻

```mermaid
stateDiagram-v2
  [*] --> DRAFT
  DRAFT --> EFFECTIVE: 增幅不超過門檻
  DRAFT --> PENDING_APPROVAL: 增幅超過門檻
  PENDING_APPROVAL --> EFFECTIVE: 覆核通過
  PENDING_APPROVAL --> REJECTED: 覆核駁回
  EFFECTIVE --> REVOKED: 撤銷
  EFFECTIVE --> EXPIRED: 到期
  REJECTED --> [*]
  REVOKED --> [*]
  EXPIRED --> [*]
```

- 門檻沿用 `product/limit.approval-threshold-minor`（以「增幅」判定）；取參失敗降級至組態預設 500,000（fail-closed）。
- **職責分離**：`approverId ≠ applicantId`（設計要求；可由組態關閉，但預設開啟）。

### 3.7 事件

- `CORP_SUB` **不發射** `limit.limit.adjusted.v1`：該事件 payload 為「**帳戶總額度**」，照發會讓授權讀模型把企業額度誤當帳戶額度（污染）。
- 企業額度以 `limit-service` **即時查詢**為權威（`CR-052 §4.1` 雙軌邊界）。

### 3.8 `PUT` 的審計化

`PUT /corp-units/{id}/limit` 保留，但實作時**同時寫一筆 `EFFECTIVE` 的 `limit_adjustments`**（`applicantId`＝操作者、`reason` 必填、`status=EFFECTIVE`），使「直接核定」也有軌跡。此為**行為變更**，須在 `SCS-002` 明示。

### 3.9 明確不做

- 不支援 `CORP_SUB` 之 `SPECIAL`（專用額度池為帳戶層語意）。
- 不改 `settlement_account_id`（歸屬帳戶變更屬組織主檔異動，另立 CR）。
- 不為 `CORP_SUB` 建立「多幣別自動補列」。

---

## 4. Stand-in 上傳載體（E）

### 4.1 現況

| 元件 | 現況 |
|---|---|
| 本地 `limit_holds` | **已有** `instrument_id`（`V14`，可空） |
| `PendingStandIn` | `(authNo, authorizationId, accountId, amountMinor, currency)`——**無載體、無型態** |
| `LimitUploadPort.uploadHold` | `(accountId, authorizationId, Money)`——**無載體、無型態** |
| 後果 | 補送後 `limit-service` 之佔用無載體 → 卡片級可用取下界、`holdAttribution=PARTIAL`；預借代佔不會帶 `CASH_ADVANCE` |

> ★ 注意：**欄位已存在**，缺的是「**記錄與上送的路徑**」——不是再開一張表。

### 4.2 語意決策：授權當下已知，就必須留下

```mermaid
sequenceDiagram
  autonumber
  participant AU as authorization-service
  participant LH as 本地 limit_holds
  participant UP as Stand-in 上送工作
  participant LIM as limit-service
  AU->>LH: 寫入佔用（accountId, instrumentId, holdType, amount）
  Note over LH: 額度服務不可用時的代佔
  UP->>LH: 掃描未上送（standin_flag=1 且 standin_upload_at IS NULL）
  UP->>LIM: uploadHold(accountId, instrumentId, authorizationId, amount, holdType)
  LIM-->>UP: holdId
  UP->>LH: 標記 standin_upload_at
```

| 決策 | 內容 |
|---|---|
| 來源 | 授權當下已由 `CardSnapshot` 取得 `instrumentId`（`#A-297`）→ **本地即應保存**，不可等到上送才回頭猜 |
| 上送內容 | `instrumentId`＋`holdType`，與即時路徑（`AuthorizeUseCase` 之 `txType` 對映）**完全一致** |
| 舊資料 | `instrumentId=null` → **照舊上送 null**，不臆造、不回填；查詢端以 `holdAttribution=PARTIAL` 揭露 |
| 是否可拒絕上送 | **不可**——缺載體不是拒絕理由，否則舊代佔永遠補不上 |

### 4.3 資料模型

- **無新 DDL**：`limit_holds.instrument_id` 已存在（`V14`）。
- 程式變更：Stand-in 本地寫入時帶入 `instrument_id`（若現行未帶）。

### 4.4 契約變更

```java
// LimitUploadPort
UUID uploadHold(UUID accountId, UUID instrumentId, UUID authorizationId,
                Money amount, String holdType);
```

```java
// PendingStandIn
record PendingStandIn(String authNo, UUID authorizationId, UUID accountId,
                      UUID instrumentId, long amountMinor, String currency,
                      String holdType) {}
```

上送請求 JSON 新增**可選** `instrumentId`、`holdType`（不升版；缺值＝`null`／`GENERAL`）。

### 4.5 相容與 fail-closed

| 情境 | 行為 |
|---|---|
| `instrumentId` 有值 | 上送帶載體；`limit-service` 佔用列寫 `instrument_id` |
| `instrumentId=null`（舊資料） | 上送 null；佔用僅歸帳戶；`PARTIAL` |
| `holdType=CASH_ADVANCE` | 上送帶型態，觸發預借子上限檢核 |
| `holdType` 缺值 | 預設 `GENERAL`（與即時路徑一致） |

### 4.6 明確不做

- 不回填歷史 `instrument_id`；不由 `cardId` 反推載體（可能一卡多載體）。
- 不改寫已上送之佔用。

---

## 5. 分批與依賴

| 批次 | 內容 | 前置 | 影響模組 |
|---|---|---|---|
| **C-1** | `V20` 歷史表＋`rebindInstrument` 埠／JDBC＋服務 | 無 | limit-service |
| **C-2** | `POST .../rebind` 端點＋權限＋稽核日誌 | C-1 | limit-service |
| **D-1** | `CorpLimitAdjustmentService`（申請）＋`CORP_SUB` 套用 | 無（`V12` 已支援） | limit-service |
| **D-2** | `POST .../limit-adjustments`＋覆核沿用＋`PUT` 審計化 | D-1 | limit-service |
| **E-1** | `PendingStandIn`／`LimitUploadPort` 加欄位＋本地寫入 | 無 | authorization-service |
| **E-2** | 上送請求帶 `instrumentId`／`holdType` | E-1 | authorization-service |
| **E-3** | 真機：代佔→上送→權威端有載體；預借型態 | E-2 | 兩服務 |

> 三項**互不阻塞**，可平行；建議順序 **E → C → D**（E 改動面最小、可先驗證載體鏈路）。

---

## 6. 驗收計畫（真機）

| 項 | 驗收 |
|---|---|
| **C** | bind→rebind：歷史 1 列、`corp_unit_instruments` 指向新單位；舊單位未釋放佔用**不變**；新佔用歸新單位；同單位重綁冪等不寫歷史 |
| **D** | 增幅≤門檻→`EFFECTIVE`；>門檻→`PENDING_APPROVAL`；核准→額度套用；駁回→`REJECTED`；撤銷→回復；`PUT` 產生 `EFFECTIVE` 軌跡；`approver=applicant` 被拒 |
| **E** | Stand-in 代佔帶載體→上送→`CC_LIM.LIMIT_HOLDS.INSTRUMENT_ID` 有值；`CASH_ADVANCE` 代佔觸發預借上限；舊資料 `null`→`PARTIAL` 且不報錯 |

---

## 7. 明確不做（避免重工）

1. **不**改寫歷史佔用歸屬（C）——改綁只影響未來。
2. **不**為 `CORP_SUB` 發射帳戶額度事件（D）——payload 語意不符。
3. **不**強制 Stand-in 舊資料補 `instrumentId`（E）——無來源即臆造。
4. **不**把 `PUT` 直接刪掉（D）——風控需要緊急覆寫；改為**留審計**。

---

## 8. 執行結果（2026-10-18，`#A-301`）

BEAM PMO 2026-10-18：「C/D/E 請實作。`cash-advance-ratio` 先以 75% 測試。」

### 8.1 逐項落地

| 項 | 落地 | 真機證據 |
|---|---|---|
| **C** | `V20` 歷史表；`CorpUnitRepository.rebindInstrument`；`CorpLimitService.rebindInstrument`；`POST /corp-units/{unitId}/instruments/{instrumentId}/rebind` | 舊佔用留原單位、新佔用歸新單位、歷史 1 列；同單位冪等 0 列；未綁定拒絕 |
| **D** | `CorpLimitAdjustmentService`；`POST /corp-units/{unitId}/limit-adjustments`；`PUT` 審計化；`CORP_SUB` 職責分離 | 未超門檻直接生效；超門檻待覆核、核准後才套用；申請人自核被拒（`CC-LIM-3001`）；`PUT` 留 `EFFECTIVE` 軌跡 |
| **E** | auth `V15` `hold_type`；`PendingStandIn`／`StandInAdviceState` 加 `instrumentId`＋`holdType`；`LimitUploadPort` 簽章；`HOLD_SQL` 寫入；查詢以純量子查詢取載體 | 後送帶 `instrumentId`／`GENERAL`；舊資料 `null` 不臆造 |
| **B** | `product-service V5`：關閉 V4 的 `0` 版本、新增 **75%**（`2026-10-18` 生效） | 版本可回溯；業務日 10-18 起取 75 |

### 8.2 測試

- `limit-service`：**全量 237 測 0 失敗**（含 `CorpLimitIntegrationTest` **12**／`CorpLimitAdjustmentIntegrationTest` **4**）。
- `authorization-service`：**全量 162 測 0 失敗**（含 `StandInUploadServiceTest`／`AuthorizationRepositoryIntegrationTest`／`AuthorizeUseCaseTest`）。
- `product-service`：**67 測 0 失敗**（`V4`／`V5` 真 Flyway；macOS 需 `-Djava.net.preferIPv6Addresses=true`）。
- 關卡：契約↔DTO 不符 **0**；`validate_sql` **0**（111 檔）；三掃描器 **0**；Mermaid **141 區塊 0 錯誤**。

### 8.3 誠實揭露

- **契約**：既有 5 個 corp 端點**本已在** `gen_openapi.py`／`limit-service.yaml`；本批新增的 `rebind`／`limit-adjustments` 已由 **`CR-059`** 補入（契約重生 **123 端點／0 錯誤**；`verify_contract_dto` **0/0**）。★ 先前「`gen_openapi.py` 無 corp 區塊」之敘述為查詢語法誤判，已更正。
- `changedBy`／`applicantId` 為**稽核標註**，非授權判斷；端點仍由服務安全層保護。
- `PUT` 直接核定以「極大門檻」送出以取得 `EFFECTIVE`——語意已文件化，非隱式魔法。

---

## 9. 修訂紀錄

| 版本 | 日期 | 內容 |
|---|---|---|
| **1.1** | 2026-10-18 | **C／D／E 實作落地並真機驗證**（`#A-301`）：C 改綁（`V20`＋rebind 端點＋forward-only）；D 企業額度申請→覆核（`CorpLimitAdjustmentService`＋`CORP_SUB` 職責分離＋`PUT` 審計化）；E Stand-in 上傳載體（`V15` `hold_type`＋埠簽章＋查詢量子查詢）。另 `product-service V5` 將預借比例設 **75%**（測試值） |
| **1.0** | 2026-10-18 | 開立。依 BEAM PMO「請您設計」凍結三項：**C 企業卡改綁**（forward-only＋`V20` 歷史表＋`POST .../rebind`）；**D 企業額度申請→覆核**（`CORP_SUB` 走同一狀態機＋`POST .../limit-adjustments`；`PUT` 保留並審計化）；**E Stand-in 上傳載體**（`PendingStandIn`／`LimitUploadPort` 加 `instrumentId`＋`holdType`，欄位已由 `V14` 存在）。實作另批，須先補 `SCS-002 §7.20～§7.22` |
