# FR-036 實作計畫 — M5（資源庫編輯讀寫改源）+ Phase 2

> M1–M4 已完成並 commit（見 design.md / changelog）。本檔聚焦尚未做的 **M5** 與 **Phase 2**。
> 盤點結論：採 **(b) 重用 jedi-oscal 既有 SSP 子表 domain service**（不重造輪子、合 DDD）。所有需要的
> provider 都已在 `di_containers/oscal/oscal_containers.py` 註冊。

---

## 已驗證的可重用建材（盤點 2026-06-10）

| 樣板 SSP 子表 | jedi-oscal domain service | 關鍵方法 | BE provider（oscal_containers.py）|
|---|---|---|---|
| ssp_control_implementations | `ControlImplementationDomainService` | `get_by_ssp_and_identifier(ssp_id, control_identifier)`、`get_all_by_ssp_id(ssp_id)`、add/update/delete_by_id | `control_implementation_domain_service`:367 |
| ssp_control_implementation_objectives | `ControlImplementationObjectiveDomainService` | get_one(filter)、get_all、add/update/delete_by_id | `control_implementation_objective_domain_service`:372 |
| ssp_components | `ComponentDomainService` | get_all/get_one/add/update/delete | `component_domain_service`:600 |
| ssp_inventory_items | `InventoryItemDomainService` | get_all/get_one/add/update/delete | `inventory_item_domain_service`:605 |
| ssp_leveraged_authorizations | `LeveragedAuthorizationDomainService` | get_all/get_one/add/update/delete | `leveraged_authorization_domain_service`:595 |
| ssp_system_characteristics | `SystemCharacteristicDomainService` | `get_by_ssp_id(ssp_id)`、add/update | `system_characteristic_domain_service`:352 |
| 程序書池 + mapping | `ssp_document_pool_query`（`SspDocumentPoolQuery`） | `list_mappings(context_type, context_id)`、`list_pool(ssp_id)`、add/remove_mapping | `ssp_document_pool_query`:503 (Singleton) |

共通 helper：`module_frame_domain_service.verify_module_frame_exists_by_uid(uid)` 回傳的 `ModuleFrameEntity`
已帶 `template_ssp_id`（M1 mapper 已補）。各 MF app service 解析 `template_ssp_id` 後用上表 domain service 讀寫。

---

## 關鍵設計約束（實查 code 得出，務必遵守）

1. **樣板 SSP 子表 row 的 key 是 AP-independent**：control 用 `control_identifier`；objective 用
   `statement_identifier`(ccai uid / letter)。**不可**用 AP task id（樣板沒有 AP）。
2. **程序書 mapping 的 context_id 差異**（最易踩雷）：
   - 專案 SSP：`SspDocumentPoolService` 用 **AP control id / AP task id** 當 context_id（line 104 註解）。
   - 樣板 SSP：M3 存的是 **樣板自己的 `ssp_control_implementations.id` / `ssp_control_implementation_objectives.id`**。
   - 所以資源庫的程序書讀寫**不能直接套** `SspDocumentPoolService`；要用 `ssp_document_pool_query.list_mappings('control_implementation', 樣板 control_impl.id)` 這種「樣板 row id」當 context_id。
3. **DTO 形狀不變**（B 路線，FE 無感）：各 `ModuleFrame*Dto.from_entity()` 是 duck-typed，jedi-oscal
   的 SSP 子表 entity 欄位名相容（control_identifier / implementation_status / … 都在），可直接餵。
4. **`is_template=False` 預設排除**（M2）：讀樣板要用 **get_by_id / get_by_uid 或顯式條件**，別走會被排除的 list；
   或直接用 ssp_id（=template_ssp_id）查子表（子表查詢不經 ssp 主表的 is_template filter）。

---

## 進度（2026-06-10）

- ✅ **M5a 完成**：6 個 default 編輯 service 全部改寫樣板 SSP（control/objective/components/inventory/leveraged/system_characteristic），DI 重接、import smoke 通過。commit `9aca4a16`(control)/`8e0913d3`(其餘 4)/objective。
- ✅ **M5c 完成**：新建 module_frame 時建樣板 SSP（add_empty_ssp + is_template/version_no）。jedi-oscal `c62ff62`、BE `b797fc57`。
- ⬜ **M5b 待做**：程序書（reference document）寫入路徑。**已知細節**：
  - `oscal.ssp_reference_documents` **無 title 欄位**（只有 description）→ MF 程序書 title 對應 file_name（與專案 SSP 一致，M3 已只搬 description）。
  - grc `ssp_reference_document_domain_service` 只有 list_by_context/add/delete_by_uid，**缺 update + get_by_uid** → 需補這兩個 method（+ repo）。
  - mapping 用 `ssp_document_pool_query.add_mappings/remove_mapping/list_mappings`，context_id = 樣板自己的 control_impl.id（用 `control_implementation_domain_service.get_by_ssp_and_identifier` 解）/ objective.id。
  - 改寫 `module_frame_reference_document_service.py`（pool CRUD + control/objective attach/detach）。
- ✅ **M5d 大部分完成**：
  - `export/ssp_mf_content_loader.py`（SSP 匯出主路徑）：control/AO/refdoc 走 M5a/M5b service（已 re-point）、inventory 改用 M5a inventory service。
  - `module_frame_ssp_resources_service._resolve_ssp_id`：改用 `mf.template_ssp_id`（不再 `get_one(profile_id)`）。
  - **⬜ 移交 Phase 2**：`ssp_import_template_app_service`（樣板下載填值 `_populate_filled_data`）—— 此 service 的 controls/ref_docs/inventory/SC/components 讀取 helper **與 Phase 2 的 import-confirm 路徑共用**、且自行注入 `*_default` domain service。只 re-point fill 半邊會切碎共用 helper、風險波及 import path（Phase 1 *_defaults 仍在）。故與 Phase 2 import 改源**一起做**。Phase 1 期間下載填值仍讀 *_defaults（M3 snapshot；M5a 編輯後在此 service 顯示為遷移初始值，屬已知 Phase-1 限制）。

---

## M5a — 6 個 default 編輯 service 改源（內容欄位）✅ 完成

每個 service 同一套改法（以 `module_frame_control_default_service.py` 為樣板，先做它驗證後複製）：

1. `__init__`：把注入的 `module_frame_*_default_domain_service` 換成對應 jedi-oscal SSP 子表 domain service。
2. 每個 public method 開頭：`mf = verify_module_frame_exists_by_uid(uid)` → `template_ssp_id = mf.template_ssp_id`；
   若 None 拋 `BadRequestError`（理論上不會：M3 已設、MF-create 會設，見 M5c）。
3. `list_all` → `get_all_by_ssp_id(template_ssp_id)`；`get_one` → `get_by_ssp_and_identifier(...)`；
   `upsert` → get → 有則 update、無則 add（entity 帶 `system_security_plan_id=template_ssp_id`）；
   `delete` → get → `delete_by_id`。
4. DTO 輸出不變；`_enrich_nicknames` 不變。
5. **DI**：在 service 的 provider 改注入 jedi-oscal domain service provider（module_frame_containers 需能
   reference `oscal_container.*`；control/objective 在 module_frame_containers.py、其餘 4 個在 oscal_containers.py，
   後者天生可直接 reference）。

逐 service：control_default → control_objective_default → components → inventory → leveraged → system_characteristic。
**每個改完跑 BE + 在資源庫 UI 實測該頁籤編輯/顯示，再做下一個。**

## M5b — 程序書（reference document）改源

1. 資源庫程序書池：`module_frame_reference_document_service` 寫入從 `module_frame_reference_documents`
   改成 `ssp_reference_documents`(context_type='ssp', context_id=template_ssp_id)，用 grc
   `ssp_reference_document_domain_service`（已有 list_by_context/add/delete_by_uid）。
2. 控制項/AO 掛勾 mapping：用 `ssp_document_pool_query`，但 **context_id 用樣板 row id**
   （control → 樣板 `ssp_control_implementations.id`；AO → 樣板 `ssp_control_implementation_objectives.id`），
   **不是** AP id。需要一支「resolve 樣板 control_impl/objective id by (template_ssp_id, identifier)」helper
   （control_impl_domain_service.get_by_ssp_and_identifier 已有；objective 用 get_one(filter)）。
3. M5a 各 service 的 `_enrich_reference_documents` 一併改用此路徑（context_id=樣板 row id）。

## M5c — MF-create 流程建立樣板 SSP

`module_frame_service.py` 建立 module_frame 後，**呼叫 `ssp_service.add_empty_ssp(..., is_template=True)`**
（需先在 jedi-oscal `add_empty_ssp` 加 `is_template: bool=False` 參數並傳入 entity）建出空樣板 SSP，
設 `module_frames.template_ssp_id`。否則新建 MF 無樣板 SSP，M5a 編輯會無處可寫。
（匯入建 MF 的 `module_frame_import_service` / docx 預選流程同樣要補。）

## M5d — 顯示 / 匯出 / 填值讀取改源

- `app/oscal/service/export/ssp_mf_content_loader.py`、`ssp_export_model.py`：讀 `*_defaults` 改讀樣板 SSP 子表。
- `app/module_frame/service/ssp_import_template_app_service.py` 的 `_populate_filled_data`（MF-scoped 填值）改讀樣板 SSP。
- `module_frame_ssp_resources_service.py:97`、`ssp_import_template_app_service.py:1002/1075` 等
  `get_one(SystemSecurityPlanQueryEntity(profile_id=...))` 想拿樣板的，改傳 `is_template=True`（M2 預設排除）。

---

## Phase 2（2026-06-10 啟動）

### P2a — 匯入 + 樣板下載填值改源（盤點結果，2026-06-10）

> 三個 service 各自獨立、無共用 confirm orchestrator。讀（填值）與寫（匯入確認）在
> `ssp_import_template_app_service` 內**共用部分 helper**（如 `_load_existing_ctrl_map`），
> 改的時候要整批一起、避免切半。共通 mapping 比照 M5a / M3（`*_default` 欄位 → `ssp_*` 欄位，
> key = `mf.template_ssp_id`；components/inventory/leveraged 帶 tenant_id + is_active）。

**A. `ssp_import_template_app_service.py`（樣板下載填值，只讀不寫）**— 6 個讀 helper 改讀樣板 SSP：
- `_fetch_ref_docs_mf_lookup()` L740 + `_build_ref_docs()` L1415 → 樣板 SSP `ssp_reference_documents`(context_type='ssp', context_id=template_ssp_id)
- `_build_sc_from_mf_default()` L1011 → `system_characteristic_domain_service.get_by_ssp_id(template_ssp_id)`（已有 `_build_system_characteristic_from_ssp`）
- `_fetch_mf_default_items()` L959 → 樣板 SSP components + leveraged（已有 `_fetch_items_by_ssp_id`）
- `_fetch_mf_inventory_entities()` L933 → 樣板 SSP inventory（已有 `_fetch_inventory_items_by_ssp_id` / `_fetch_system_assets_by_ssp_id`）
- `_load_existing_ctrl_map()` L1389 + `_load_existing_obj_map()` L1410 → 樣板 SSP control_implementations / objectives（keyed by control_identifier /（control_identifier, statement_identifier））
- 最簡做法：`_populate_filled_data` 解析 `tssp=mf.template_ssp_id`，SC/components/leveraged/inventory 直接走既有 `_*_by_ssp_id` helper；control/AO/refdoc helper 改吃 template_ssp_id。

**B. `ssp_docx_import_app_service._run_mf_default_confirm()` L1078-1268（匯入確認，DELETE-all + upsert）**
- LA/Component/Inventory/SC 四段，從 `mf_*_default_domain_service` 改成 jedi-oscal domain service（keyed by template_ssp_id），entity 轉 `ComponentEntity`/`InventoryItemEntity`/`LeveragedAuthorizationEntity`/`SystemCharacteristicEntity`（帶 tenant_id + is_active）
- 觸發條件 L870 的 `if source_type=='module_frame' and (mf_*_default ...)` 一併調整
- 控制項/AO 寫入（`module_frame_control_default_repo` / `_objective_default_repo` L53-54）改寫樣板 SSP control_implementations / objectives

**C. `ssp_excel_import_app_service._apply_write_mf_defaults()` L1707-1866（同 B 模式）**
- LA/Component/Inventory/SC 四段改寫樣板 SSP；`ModuleFrameWriteStrategy.write_excel_control_defaults`（控制項/AO，L1619）改寫樣板 SSP

**D. DI（oscal_containers 993-996 / 1047-1051 / excel container）**：三個 import service 停止注入 `mf_*_default_domain_service`，改注入 jedi-oscal domain service（component/inventory/leveraged/system_characteristic/control_implementation/objective）。

### P2b — gate + copy service 清理（code，可逆）
1. `_setup_ssp_system_implementation` 的 `_mf_has_defaults` gate（oscal_project_service ~L665）改查 `module_frames.template_ssp_id IS NOT NULL`；移除舊式「profile 第一個 SSP 當 template」clone。
2. `ModuleFrameTemplateCopyService.__init__` 移除已不用的 4 個 `*_default` domain service 注入。

### P2c — DROP `*_defaults` 子系統（破壞性、不可逆，安全底線）
- **前置（不可跳）**：stg / poc 套用 M1（欄位）+ M3（資料遷移）→ 三環境驗證零丟失 → **user 明確點頭**。
- DROP `module_frame_*_defaults`（7 張）+ `module_frame_reference_documents`/`_mappings`；移除整個子系統 model/repo/domain service/mapper/entity/query-entity/DTO/route。

---

## 驗證（每階段）

- M5a/b 每個 service：BE 重啟 → 資源庫該頁籤 新增/修改/刪除/顯示 → 再啟動專案確認內容有從樣板帶入。
- M5c：新建一個 MF → 確認 `template_ssp_id` 有值 → 編輯 → 啟動專案帶入。
- 跨環境：dev 全綠後再 stg（先備份）、poc。
