# FR-038 框架維護 2a/2b 再啟用 — 實作計畫

> 範圍：把 Wave 2A disable 的三組「框架維護」route 重寫接 v2、**註冊回舊 URL、對齊舊 shape → FE 零改動**。
> 上游決策：framework CRUD 已用此原則 ship（BE 路由接回 `/oscal-frameworks*`、`/oscal-framework*`，FE 未動）。
> 撰寫日：2026-06-16　branch：`feature/oscal-refactor`（BE + 套件）

## 0. 核心原則（每階段共用）

1. **FE 零改動**：route 一律註冊回**舊 URL**（`/oscal-framework-versions`、`/oscal-catalog-group` …），主專案 app service 當 adapter 對齊舊 request/response shape。
2. **絕不 import v1 `jedi_oscal` 進 boot graph**（撞 v2 MetaData 直接炸 — FR-038 第一鐵則）。所有重寫只接 `jedi_oscal_v2` / v2 repo / raw SQL。
3. **canonical pattern**（B1 已驗）：新/改 app service 包 v2 primitive → OscalContainer wire → route 改 import 接新 service → `EXCLUDE_MODULES` 移除 + `api/oscal/__init__.py` 註冊舊 URL → BOOT + smoke + pytest 不多紅 → 顯式 git add commit。
4. **動 v2 套件**：dev path-dep（BE 重啟生效），**不發 Nexus、不 commit pyproject**，整 feature 完 + user 明示才發版。
5. 每階段獨立可 ship、可手測；FE 全程不需改。

## 1. 三階段排序（風險低 → 高）

| 階段 | 內容 | FE 頁面 | 風險 | 主要 BE 工 |
|------|------|---------|------|-----------|
| **P1 = 2a 版本管理** | 版本 list（樹）/menu/get/create/update/delete | `ComplianceFrameworkVersionManage` | 低（B1 pattern）| 新 `FrameworkVersionAppService`；v2 `FrameworkService` 補 `update_version`/`delete_version`/樹過濾 |
| **P2 = 2b catalog 編輯** | catalog-tree + group/control/AO 改/刪（draft+無 profile 引用守門）| `FrameworkVersionEditView` | 中 | 改寫 `framework_version_edit_service` 接 v2 catalog repos；v2 `CatalogService` 補 group/control/part update/delete |
| **P3 = 兩階段 PDF 匯入** | parse-job：parse→預覽→confirm（decisions/overrides）| `FrameworkImportPage` | **高** | parse-job service 重寫成 **v1-clean**；需 v2「parse PDF→dict（不寫入）」能力 |

---

## P1 — 2a 版本管理

### 端點（舊 URL，FE 既有）
| URL | method | 用途 |
|-----|--------|------|
| `/oscal-framework-versions` | POST | 版本分頁列表，filters：`framework_uid` / `parent_version_uid` / `is_root` |
| `/oscal-framework-version/<uid>` | GET | 版本詳情 |
| `/oscal-framework-version` | POST | 新增版本 |
| `/oscal-framework-version/<uid>` | PUT | 更新版本 |
| `/oscal-framework-version/<uid>` | DELETE | 刪版本 |
| （版本 menu 若 FE 有用）| GET | 版本樹 menu |

### FE response shape（adapter 要回的欄位）
`uid, version, release_date, publish_status, framework_uid, framework_name, parent_version_uid, parent_version, catalog{概覽}, is_root, children[], created_user/_name, updated_user/_name, created_at, updated_at`
> 註：v2 `framework_versions` 欄位 = uid/framework_id/parent_id/version/catalog_id/publish_status/published_at；`release_date` v2 schema 是否有需驗（沒有就回 null 或用 published_at）。

### 步驟
1. **v2 套件 `FrameworkService` 補**（dev path-dep）：
   - `update_version(entity)`、`delete_version(uid)`（repo 已有 `update`/`delete_by_uid`）
   - `list_versions_by(query)` 支援 `parent_id` / is_root（parent_id IS NULL）過濾（query entity 已有 framework_id + parent_id）
2. **新 `app/oscal/service/framework_version_app_service.py`**（`@transaction`，包 v2 FrameworkService）：menu / list+pager（樹過濾 + framework_name enrich）/ get / create / update / delete；對齊舊 shape。
3. **DI**：`oscal_containers.py` 加 `framework_version_app_service` provider。
4. **route**：`oscal_framework_version_route.py` 改 import 新 app service（拔 v1 `OscalFrameworkVersionService`）。
5. **re-enable**：`config/di_modules.py` 移除 `oscal_framework_version_route`；`api/oscal/__init__.py` 註冊舊 URL。
6. BOOT + smoke（用既有 framework）+ pytest baseline diff + commit。

### 待驗
- v2 `framework_versions` 有無 `release_date` 欄位（FE 讀 release_date）；無 → adapter 補 null 或對應欄位。
- FE 版本列表是否硬依賴 `catalog` 概覽（控制數）；需要就 enrich。

---

## P2 — 2b catalog 編輯

### 端點（舊 URL）
| URL | method | 守門 |
|-----|--------|------|
| `/oscal-framework-version/<uid>/catalog-tree` | GET | — |
| `/oscal-catalog-group/<uid>` | PUT / DELETE | draft + 無 profile 引用 |
| `/oscal-catalog-control/<uid>` | PUT / DELETE | 同上（PUT 支援跨 group 搬家）|
| `/oscal-catalog-control-assessment/<uid>` | PUT / DELETE | draft（PUT 支援跨 control 重綁）|

### 步驟
1. **v2 套件 `CatalogService` 補**（dev path-dep）：`get_catalog_tree_by_version` 或重用既有 `get_control_tree`；`update_group/update_control/update_part`、`delete_group/delete_control/delete_part`（repo 已有 update/delete_by_uid + 級聯需自寫）。
2. **改寫 `app/oscal/service/framework_version_edit_service.py`**：拔 v1 catalog domain service import，改接 v2 CatalogService / v2 catalog repos；保留雙軌守門（`_require_draft` + `_has_profile_references` 改查 v2 profile_imports）。
3. **DI**：provider 改 wire v2。
4. **re-enable**：移除 `framework_version_edit_route`（含 catalog_control_assessment 同檔）；註冊舊 URL（`/oscal-catalog-group` 等）。
5. BOOT + smoke（改/刪一個 draft catalog 的 group/control/AO）+ pytest + commit。

### 待驗
- has_references 判定：v2 profile 引用控制集的查法（profile_imports → 控制清單）。
- 跨 group 搬家 / 跨 control 重綁在 v2 model 的外鍵（control.catalog_group_id / part.catalog_control_id）。

---

## P3 — 兩階段 PDF 匯入（parse-job）

### 端點（舊 URL）
| URL | method | 用途 |
|-----|--------|------|
| `/oscal-framework-parse-jobs` | GET | 列進行中草稿（status filter）|
| `/oscal-framework-parse-jobs/parse` | POST | Step1 上傳 PDF（multipart）→ 解析存 job |
| `/oscal-framework-parse-jobs/<uid>` | GET | 取 parsed_result 預覽 |
| `/oscal-framework-parse-jobs/<uid>/confirm` | POST | Step2 decisions+overrides → 寫 catalog/version |
| `/oscal-framework-parse-jobs/<uid>` | DELETE | 軟刪 |

### parsed_result 結構（FE 既有）
`{ groups:[{uid,name,description,parent_group_uid,order_no}], controls:[{uid,control_id,control_title,description,guidance,group_uid,order_no}], assessments:[{uid,name,description,control_uid,order_no}] }`

### 關鍵風險 / 設計
- **parse-job service 現用 v1 `OscalImportService.parse_pdf_to_dict` + `import_from_dict`** → 不能進 boot graph。
- **需 v2「parse PDF → dict（不寫入）」能力**：v2 `CatalogService.import_catalog_from_pdf` 是 parse+寫一次完成；兩階段需「只 parse 出 dict 供預覽」。
  - 方案 A：v2 補 `parse_pdf_to_dict(stream, parser_code) -> dict`（把 v1 parser 邏輯搬進 v2，與 import_catalog_from_pdf 共用底層 parser）。
  - 方案 B：confirm 時用 decisions/overrides 整理出最終 dict，再走 v2「from dict 寫入」（需 v2 有 `add_catalog_from_dict` 或用 add_catalog + entity 組裝）。
- `oscal.framework_parse_jobs` 表在主專案 infra（已存在）；parsed_result/import_summary 是 JSONB，與套件無關，可留。
- parse-job domain service / repo 在主專案，檢查是否 v1-clean（不 import jedi_oscal）。

### 步驟
1. 先釘死 v2 parse 能力（讀 `import_catalog_from_pdf` 內部 parser，抽出 parse-only）。
2. v2 套件補 `parse_pdf_to_dict` + 「from dict 寫入（含 decisions 後的最終 dict）」。
3. 重寫 `framework_parse_job_service` 接 v2（拔光 v1 import）。
4. DI wire + 移除 EXCLUDE + 註冊舊 URL（4 條）。
5. BOOT（先 import-probe 確認無 v1 NameError）+ real PDF smoke（CMMC L2）+ pytest + commit。

---

## 2. 共用驗證 / 收尾
- 每階段：`create_app()` BOOT OK + `python -m pytest test/` 不多於 baseline 紅（baseline-diff）+ 顯式 git add commit（BE 與套件分開）。
- FE 全程不需改；手測走既有三頁。
- push / 收尾（changelog/SUMMARY/memory/Notion）/ 套件發版 → **等 user 明示**。
- `pyproject.toml` dev path-dep 勿 commit。

## 3. 不在本計畫
- profile route（`oscal_profile_route`）/ catalog_control_assessment 獨立 route — 視 FE 是否用到再評估。
- 資源庫 / 專案 / SSP / AP / AR 等其他 B 區塊。
