# FR-062.7 T-7.2 既有租戶發照 Migration — 執行 SOP

> 對應 Notion：T-7.2＝CM-1136｜子需求 FR-062.7＝CM-1134｜母案 CM-1112
> 撰寫日期：2026-08-09（DEV 演練，本文件同時作為未來 STG／POC 上版時的操作依據）

---

## 🔴 執行前必讀：環境範圍

**本文件目前僅適用 DEV。STG／POC 需決策者「當次」明確放行後才可執行**——這是 D14／
CM-1136 卡片的明文約束（見 design.md §5 T-7.2 一列、`common/authz/license.py` 檔頭注解）。
腳本本身也內建護欄：`DB_NAME` 不在允許清單（目前只有 `guidant_ai_dev`）會直接拒絕執行，
不提供 `--db-name` 逃生門（與 FR-059 seed script 的顯式確認模式不同，這是刻意設計——
T-7.2 這一支涉及對外發照，比對照庫 seed 風險更高，STG/POC 版本需要另外規劃 customer_code
命名與私鑰座標，不應該用同一份腳本無腦套過去）。

執行中任何一步結果與本文件描述不符：**停下回報，不要自行補救或跳步**。

## 0. 這支腳本做什麼

為 DEV 環境「已存在但尚未持有現行照」的租戶（root tenant 除外）各發一張**內部長效照**：

| 欄位 | 值 | 理由 |
|---|---|---|
| `plan` | `enterprise`（全模組） | 內部測試用途，不限制功能 |
| `type` | `formal` | D14 最終修正：直接發正式規格照，無過渡照、無豁免分支 |
| `deployment_mode` | `saas` | DEV/STG 走 SaaS 驗證路徑，不綁機器指紋 |
| 效期 | 365 天 | 卡片要求「約一年」 |
| `expiry_policy` | notify／grace／readonly／lockout **全部關閉** | design.md §4.5「全部關閉＝形同永久照，內部長效照即此組態」；DEV/STG 是內部環境，不需要真的走到期管線 |

**冪等**：跑之前先查每個租戶是否已有 `is_current=true` 的現行照，有就跳過（`[skip]`）。
重複執行不會重複發照。

**root tenant 排除**：root（`tenants.id=1`）永遠不持照，這是設計如此（見
`common/authz/license.py` 檔頭「易踩雷點一」），腳本會自動跳過並印 `[skip]`。

## 1. 前置確認

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
grep DB_NAME .env   # 必須是 guidant_ai_dev，不是就先停下
```

確認 `~/Projects/Billows/Audit-Manager/license_center/.env` 存在且含
`LICENSE_CENTER_KEY_PASSPHRASE`（腳本靠此自動解鎖私鑰，不會互動詢問）。

**先盤點現況**（不要假設哪些租戶已有照——T-7.1／T-7.3 驗收期間插過測試短效照，狀態會變）：

```bash
export PGPASSWORD='<查 .env DB_SECRET>'
psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
  -c "SELECT id, tenant_id, license_id, license_type, status, is_current, expires_at, plan
      FROM config.tenant_licenses ORDER BY tenant_id, id;"
psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
  -c "SELECT id, name, parent_id FROM tenants ORDER BY id;"
```

## 2. 執行

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be

# 先 dry-run（只印會對哪些租戶發照，不寫入）
poetry run python scripts/migrate_2026-08-09_fr062_existing_tenant_licenses.py

# 確認 dry-run 清單符合預期後才 --apply
poetry run python scripts/migrate_2026-08-09_fr062_existing_tenant_licenses.py --apply
```

輸出開頭會印 `DB : guidant_ai_dev @ 192.168.50.188`——**核對這一行是目標環境**。
逐租戶結果標記：`[skip]`（root 或已有現行照）／`[ok] <租戶名> → license_id=...`／
`[fail] <租戶名> <原因>`。結尾印 `總計：created=N skipped=N failed=N`。

**有任何 `[fail]`：停下回報，不要重跑**（重跑前先確認失敗原因，可能是私鑰
passphrase、DB 連線、或 `license_center` 端 `plans` 表缺 `enterprise` seed）。

## 3. 部署後驗收 checklist

以 root 帳號（DEV：`admin` / 密碼查 `.env` 或密碼管理器）登入平台管理頁，逐項確認：

- [ ] 進「授權管理」總覽頁（root 專屬），非 root 租戶（131／152／153，或當次 DB 實際
      查到的目標租戶）皆顯示 `has_license=true`，`plan=enterprise`，`status=valid`
- [ ] 任一該租戶的一般使用者登入，`GET /license/status` 回傳現行照、`modules` 含全部
      27 個模組鍵、`is_root_tenant=false`
- [ ] 該租戶使用者可正常操作既有功能（開專案、看儀表板等，掛
      `@require_license(...)` 的端點不再 403）
- [ ] **重跑一次腳本**（不加 `--apply` 先 dry-run 確認全部 `[skip]`，再 `--apply` 確認
      `created=0`）——驗證冪等，不會疊加或換發
- [ ] root tenant（`Guidant.AI`）`config.tenant_licenses` 內**沒有**因本次腳本新增的紀錄
      （root 原本若有照是先前其他任務留下的，本腳本不動它）
- [ ] **新開一個租戶**（無指派任何照）：以該租戶使用者登入，SaaS 版應看到「請聯繫管理員」
      提示（非 host 版開通頁）——此路徑是 T-5.1 既有行為，本次只驗證未被本次改動影響

任何一項不符：停下回報。

## 4. 驗證 SQL（供人工複查）

```sql
SELECT tl.tenant_id, t.name, tl.license_id, tl.license_type, tl.status, tl.plan,
       tl.deployment_mode, tl.expires_at, jsonb_object_keys(tl.modules) AS module_count
FROM config.tenant_licenses tl
JOIN tenants t ON t.id = tl.tenant_id
WHERE tl.is_current = true
ORDER BY tl.tenant_id;
```

預期：除 root（id=1）外，每個租戶恰有一列 `is_current=true`、`status=valid`、
`plan=enterprise`、`deployment_mode=saas`、`expires_at` 約落在執行日起算一年後。

## 5. 失敗重跑方式

腳本本身冪等，**直接重跑 `--apply` 即可**，不需要先清資料：

- 已成功入庫的租戶會因 `has_license=true` 被跳過，不會重複發照或觸發 D6 換發。
- 失敗的租戶（`[fail]`）下次重跑會重新嘗試（因為還沒有現行照）。
- 若某租戶簽發成功但入庫失敗（`license_center` 端 `license_issuance` 已留一筆紀錄，
  但 `tenant_licenses` 沒有），重跑會為它**再簽一張新的**（`license_center` 端允許
  同租戶多筆歷史簽發紀錄，不影響——`tenant_licenses` 只認最後一次成功入庫的為現行）。
  這不是問題，`license_issuance` 本就是稽核留存表，不要求一對一。

若要清掉某次測試發的照重來（僅限 DEV 除錯用，正式 migration 不應該需要）：

```sql
-- 僅 DEV，僅除錯用：作廢某租戶當前照，讓下次腳本重新視為「無照」重發
UPDATE config.tenant_licenses SET is_current = false WHERE tenant_id = <id> AND is_current = true;
```

## 6. 已知限制 / 未涵蓋範圍（刻意，非遺漏）

- **本 SOP 不含 STG／POC 執行步驟**——那是 FR-062 上版流程的下一段，需要決策者當次放行
  後另開派工，並且需要另外規劃該環境的 `customer_code`／`order_no` 命名慣例與
  `license_center` 私鑰座標（不能沿用 DEV 的 `INTERNAL-<uid前綴>` 佔位命名）。
- **本腳本不處理「已有現行照但即將到期」的續發**——那是換照／到期管線（T-3.1／T-7.1）
  的職責，本腳本只補「完全沒有現行照」的空缺。
- **子租戶不單獨發照**——照綁頂層客戶租戶，效力涵蓋子孫租戶（design.md D8）。若某
  頂層租戶下有子租戶但頂層已有照，子租戶不會被腳本誤判為「需要發照」（腳本邏輯是
  逐租戶查 `has_license`，子租戶若自己沒有 `tenant_licenses` 現行照列會被視為需要，
  這與 D8「效力涵蓋子孫」的執法讀取邏輯是兩件事——執法讀的是**該租戶自己**的現行照，
  不會往上層查找；因此嚴格說每個租戶各自需要一張現行照，這正是本腳本「逐租戶掃描」
  而非只掃頂層的原因，行為已在 DEV 驗證：131／152／153 三個不同層級的租戶皆各自拿到照）。
