# 合規框架 PDF 匯入兩階段化 — 設計說明書（SD）

> **文件版本**：1.1
> **建立日期**：2026-05-03
> **最後更新**：2026-05-04（合併 v2.1 迭代）
> **文件性質**：SD — 系統設計（給後端工程師 / 前端工程師 / DBA）
> **對應 Phase 0**：[raw-requirement.md](./raw-requirement.md)
> **對應 SA**：[api-spec.md](./api-spec.md)
> **對應 FE Spec**：[frontend-spec.md](./frontend-spec.md)

---

## 0. 迭代紀錄

| 日期 | 版本 | 變更摘要 | 對應 Changelog |
|------|------|---------|---------------|
| 2026-05-03 | 1.0 | 初版實作 — 4 端點 + staging table + jedi-oscal 0.0.16 拆分 | `2026-05-03-feat-framework-pdf-import-v2-be.md` / `2026-05-03-feat-jedi-oscal-import-service-split.md` |
| 2026-05-04 | 1.1 | v2.1 三項迭代：(a) Step 1 parser-only — metadata 移到 Step 2；(b) 重新上傳 PDF 覆蓋既有版本（target_version_uid）；(c) 合規框架版本即時編輯 4 端點 + 雙軌守門 | `2026-05-04-tweak-framework-import-step1-parser-only.md` / `2026-05-04-feat-framework-version-reupload-overwrite.md` / `2026-05-04-tweak-backfill-framework-version-file-uid.md` |

---

## 1. 範圍

把合規框架版本（OSCAL Framework Version）的匯入流程從「Dialog → 上傳 PDF → 一次寫入 catalog/profile/framework_version」改為三階段：

1. **Step 1（上傳）**：FE 路由頁面收檔 + 版本 metadata，BE 解析 PDF 後存入 staging table（`oscal.framework_parse_jobs`），不寫 catalog 系列正式表
2. **Step 2（預覽編輯）**：FE 拿 staging 資料逐條顯示 group / control / AO，使用者可 Update / Delete / 跨 group 搬移；編輯狀態僅存於 LocalStorage
3. **Confirm**：FE 把編輯結果（decisions + overrides）送回 BE，BE 套用後呼叫 jedi-oscal 寫入 catalog/profile/framework_version

不在範圍：
- Excel 匯入流程（保留現有 `OscalImportRoute` 單階段路徑）
- 新增 group/control/AO（僅允許 Update / Delete）
- Per-control review / 審核 workflow

## 2. 架構與層級

```
api/oscal/routes/framework/
└── oscal_framework_parse_job_route.py   ← 新增：四端點

app/oscal/service/
└── oscal_framework_parse_job_service.py ← 新增：app service（@transaction，組合 domain + jedi-oscal）

domain/oscal/service/
└── oscal_framework_parse_job_domain_service.py  ← 新增：domain service

domain/oscal/repository/
└── oscal_framework_parse_job_repo.py    ← 新增：repo interface

infra/oscal/
├── model/oscal_framework_parse_job.py   ← 新增：ORM model
├── mapper/oscal_framework_parse_job_mapper.py
└── repository/oscal_framework_parse_job_repo_impl.py

di_containers/oscal/oscal_containers.py  ← 串接以上 + 注入 jedi-oscal `OscalImportService`

scripts/sql/2026-05-03-oscal-framework-parse-jobs.sql  ← migration
```

DDD 規範：
- Route 不查 DB / 不 import ORM model
- App Service 用 `@transaction`，組合 domain service 與 jedi-oscal 套件
- Repo 繼承 `BaseRepositoryImpl`（session lazy），不在 `__init__` 提前讀 session

## 3. 資料庫 Schema

### 3.1 新表：`oscal.framework_parse_jobs`

仿 `oscal.ssp_docx_parse_jobs` schema，欄位語意對應如下：

| 欄位 | 型別 | 必填 | 說明 |
|------|------|------|------|
| `id` | SERIAL | Y | PK |
| `uid` | UUID | Y | 對外公開 identifier，default `gen_random_uuid()` |
| `tenant_id` | INT | Y | RLS 隔離；同 tenant 共享 parse_job |
| `created_user_id` | INT | Y | 上傳者 user.id（不做使用者過濾，僅留 audit） |
| `status` | VARCHAR(32) | Y | `pending` / `parsing` / `awaiting_review` / `completed` / `failed` / `expired` |
| `file_name` | VARCHAR(255) | Y | 上傳檔名 |
| `file_size` | BIGINT | Y | 上傳檔案大小（bytes） |
| `parser_type` | VARCHAR(32) | Y | PDF 解析器類別（同現有 dialog 的 dropdown 值） |
| `target_framework_uid` | UUID | Y | 目標 framework UID（Step 1 metadata） |
| `target_version` | VARCHAR(64) | Y | 目標 version 字串 |
| `target_release_date` | DATE | Y | 發行日期 |
| `target_publish_status` | VARCHAR(32) | Y | `draft` / `published` / `deprecated` |
| `target_parent_version_uid` | UUID | N | 若為子版本 |
| `parsed_result` | JSONB | N | jedi-oscal `parse_pdf_to_dict()` 輸出（catalog/groups/controls/assessments 階層） |
| `error_code` | VARCHAR(32) | N | 解析失敗時填 |
| `error_message` | TEXT | N | 解析失敗訊息 |
| `result_framework_version_uid` | UUID | N | confirm 成功後填回的 framework_version.uid |
| `is_active` | BOOLEAN | Y | default `TRUE`；TTL 過期或 confirm 完成後設 `FALSE` |
| `created_user` | VARCHAR(64) | Y | login_name |
| `updated_user` | VARCHAR(64) | Y | login_name |
| `created_at` | TIMESTAMPTZ | Y | default `now()` |
| `updated_at` | TIMESTAMPTZ | Y | default `now()` |

**Index**：
- `(tenant_id, is_active, status)` — 列當前 tenant 進行中草稿（Q5 banner）
- `(uid)` — 對外查詢
- `(created_at)` — TTL cleanup

**RLS**：跟 `ssp_docx_parse_jobs` 同 policy（依 `tenant_id` 過濾）

**權限授予**：
```sql
GRANT SELECT, INSERT, UPDATE, DELETE ON oscal.framework_parse_jobs TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE oscal.framework_parse_jobs_id_seq TO cm_app;
```

### 3.2 不新增其他表

confirm 階段直接寫入既有的 `oscal.catalogs / catalog_groups / catalog_controls / catalog_control_assessments / profiles / profile_controls / framework_versions`，由 jedi-oscal `import_from_dict()` 處理。

## 4. 核心商業邏輯

### 4.1 狀態機（`framework_parse_jobs.status`）

```
pending ─upload─▶ parsing ─parse OK──▶ awaiting_review ──confirm──▶ completed
                       │                       │
                       │                       └──discard──▶ (is_active=false)
                       │
                       └─parse fail──▶ failed
                                          │
                                          └──TTL 7d──▶ expired (cron)
```

- `pending` 為 enum 預留，目前 parse 是同步 → 直接從 `parsing` 起算
- `awaiting_review` / `failed` 兩個 terminal-ish 狀態都會經 7 天 TTL → `expired`（軟刪 `is_active=false`）
- `completed` 後 `is_active=false`，但仍保留 row 給 `result_framework_version_uid` audit trail

### 4.2 Parse 流程（`POST /parse`）

1. App service 驗證 `target_framework_uid` 存在且屬當前 tenant
2. 若帶 `target_parent_version_uid`：驗證該 version 屬同 framework
3. **若帶 `target_version_uid`**（v1.1 覆蓋模式）：版本存在 + 屬同 framework + `publish_status='draft'` + 無引用；自動帶入既有版本的 version / release_date / publish_status 到 parse_job 的 `target_*`
4. **Metadata 三欄（version / release_date / publish_status）改為 optional**（v1.1）— 若帶值就寫進去，沒帶就 NULL
5. 建立 `parse_job` row（`status=parsing`）
6. 呼叫 `jedi_oscal.OscalImportService.parse_pdf_to_dict(file_stream, parser_type)`
   - 成功：將 dict 寫入 `parsed_result`，狀態轉 `awaiting_review`
   - 失敗：捕捉 exception → 寫入 `error_code` / `error_message`，狀態轉 `failed`
7. **無論成功失敗都回 200**，body 帶 `parse_uid` 與 `status`，FE 統一導去 `/import-version/<uid>`，由該頁面依 status 決定渲染預覽或錯誤面板

> **v1.0 → v1.1 變化**：版本重名檢查（`(framework_id, version)` 衝突）從 parse 移到 confirm。原因 — Step 1 改 parser-only 後 version 可能還沒填，無法在 parse 階段預檢。

### 4.3 Confirm 流程（`POST /<uid>/confirm`）

接收 payload：
```json
{
  "decisions": [
    { "type": "group", "uid": "...", "action": "delete" },
    { "type": "control", "uid": "...", "action": "delete" }
  ],
  "overrides": {
    "groups":   [{ "uid": "...", "name": "...", "description": "...", "order_no": 1, "parent_group_uid": "..." }],
    "controls": [{ "uid": "...", "control_id": "...", "control_title": "...", "description": "...", "guidance": "...", "order_no": 1, "group_uid": "..." }],
    "assessments": [{ "uid": "...", "name": "...", "description": "...", "order_no": 1, "control_uid": "..." }]
  }
}
```

執行步驟（皆在同一 `@transaction`）：

1. 取出 parse_job，檢查 `status='awaiting_review'` 且 `is_active=true`；否則 409
2. **依 `target_version_uid` 分流（v1.1）**：
   - **新建模式**（`target_version_uid` 為 null）：
     - Metadata 三欄取 `caller payload` 或 `parse_job.target_*` 任一處有值；都沒有 → 400 `GRC_FRAMEWORK_PARSE_JOB_METADATA_REQUIRED`
     - 把 caller 帶的 metadata 寫回 parse_job（讓 retry 可一致）
     - 檢查 `(framework_id, version)` 不衝突（v1.1 從 parse 移到此處）
   - **覆蓋模式**（`target_version_uid` 有值）：
     - 雙軌守門再驗一次（避免 race）：版本仍是 draft + 仍無引用
     - Metadata 三欄忽略 caller 輸入，沿用既有版本
3. **套用 decisions**：從 `parsed_result` 移除被標 `delete` 的 group/control/AO
4. **套用 overrides**：對留下來的條目以 `uid` 為 key 合併 override 欄位（含 `parent_group_uid` / `group_uid` / `control_uid` 重綁）
5. Validation：剩餘 controls > 0；`parent_group_uid` / `group_uid` / `control_uid` 不能指向已被刪除的 uid
6. 呼叫 `jedi_oscal.OscalImportService.import_from_dict(merged_dict, payload, curr_user, locale, version_uid=<優先 target_version_uid 否則 None>)`：
   - `version_uid=None` → 走 `_create_version_from_hierarchical`（新建）
   - `version_uid=<既有>` → 走 `_update_version_from_hierarchical`（版本 uid 不變、舊 catalog 標 DEPRECATED、新 catalog 接到同 framework_version、更新 file_uid）
7. 寫回 `parse_job.status='completed'` / `is_active=false` / `result_framework_version_uid=<新建/原版本 uid>`
8. 回傳 framework_version DTO

### 4.4 同 tenant 共享 / Last-write-wins

- 任何同 tenant 使用者都看得到、編得到 awaiting_review 的 parse_job
- 編輯狀態完全在 FE LocalStorage（key 含 `parse_uid` + 當前 user uid）— 不同使用者各自的 draft 互不影響
- Confirm 時誰先 commit DB 誰勝；後到的會在 step 1 拿到 `409 GRC_FRAMEWORK_PARSE_JOB_ALREADY_CONFIRMED`，FE 顯示「已被 X 確認」並引導重新上傳

DB 層另以 `UNIQUE(framework_id, version)` 擋真正的 race（兩 confirm 同時建版本）。

### 4.5 TTL 清理

新增 cron job（`core/scheduler.py`）：每天清一次 `is_active=true AND created_at < now() - interval '7 days'`，將 `is_active` 設 `false`、`status='expired'`。

## 5. jedi-oscal 改動（→ 0.0.16）

### 5.1 拆分 `OscalImportService`

```python
# jedi_oscal/app/services/oscal_import_service.py

class OscalImportService:
    def parse_pdf_to_dict(
        self,
        file_stream: BinaryIO,
        parser_type: str,
    ) -> dict:
        """純解析，回傳 OSCAL-shaped dict，不寫 DB、不需 session。"""
        ...

    def import_from_dict(
        self,
        oscal_dict: dict,
        payload: ImportFrameworkVersionPayload,  # dataclass
        curr_user: str,
        locale: str = None,
        version_uid: str = None,
    ) -> FrameworkVersionEntity:
        """從 dict 寫 catalog/profile/framework_version。需在 @transaction 內呼叫。"""
        ...

    # Backward compat：保留現有單階段入口給 Excel 路徑與舊 caller
    def import_or_update_oscal_from_pdf(
        self, audit_file_type, file_stream, payload, curr_user, locale, version_uid=None
    ):
        oscal_dict = self.parse_pdf_to_dict(file_stream, audit_file_type)
        return self.import_from_dict(oscal_dict, payload, curr_user, locale, version_uid)
```

### 5.2 新增 `ImportFrameworkVersionPayload` DTO

取代既有散裝的 dict 參數：
```python
@dataclass
class ImportFrameworkVersionPayload:
    framework_uid: str
    version: str
    release_date: date
    publish_status: str
    parent_version_uid: Optional[str] = None
```

### 5.3 進版

- 版本：0.0.15 → 0.0.16
- BE `pyproject.toml` jedi-oscal pin 改 `==0.0.16`
- 由使用者跑 `poetry update`（依 memory: feedback_poetry_update_only）

## 6. 與既有模組的整合點

| 模組 | 影響 |
|------|------|
| `OscalImportRoute`（單階段 PDF/Excel）| 不動，仍受其他流程使用；內部委託 jedi-oscal `import_or_update_oscal_from_pdf`（已被改為兩段呼叫） |
| `ComplianceFrameworkVersionManage.vue` | 「匯入版本」按鈕改 `router.push`；移除 dialog template / handlers |
| RBAC 系統 | 新路由 `/compliance-framework/import-version` 需在 `ui_routes` / `route_capabilities` 註冊（依 reference_permission_system_sop） |
| `core/scheduler.py` | 新增 cron job：`framework_parse_job_cleanup`（仿既有 `ssp_docx_parse_job_cleanup` 若有） |

## 7. 安全 / 邊界

- 上傳檔案大小：Flask `MAX_CONTENT_LENGTH=50MB`（沿用現有 config）
- `target_framework_uid` 必須屬當前 tenant（透過既有 framework domain service `get_by_uid` + RLS 自動隔離）
- `target_parent_version_uid` 若帶必須屬同 framework，且本身不為 deprecated
- LocalStorage draft：key 含 `user_uid`，不同使用者瀏覽器互不污染
- Parse 失敗的 `error_message` 可能含 PDF 解析 stack trace — BE 過濾後僅暴露 user-friendly message，原始 trace 寫 log

## 8. 觀測性

- 每次 parse 寫 INFO log：`framework_parse_job.parse_started uid=<u> file_name=<f> parser_type=<p>`
- 失敗寫 ERROR log + Sentry：含 file_size / parser_type / 摘要前 200 字 stack trace
- Confirm 成功寫 INFO：`framework_parse_job.confirmed uid=<u> result_version_uid=<v> deletes=<n_deletions> overrides=<n_overrides>`

## 9. 變更紀錄需求

完成後在 `docs/changelog/` 建立至少 3 份：
- `feat-framework-pdf-import-v2-be.md`（含 schema migration、jedi-oscal 0.0.16 進版、四個新端點）
- `feat-framework-pdf-import-v2-fe.md`（路由、頁面、入口替換）
- `tweak-framework-pdf-import-v2-cleanup-cron.md`（TTL cleanup 新 scheduler job）

依各自影響範圍若需拆更細則拆。

---

## 10. 重新上傳 PDF 覆蓋流程（v1.1 新增）

### 10.1 動機

舊「更新文件」是單階段 PUT — 直接整本蓋掉，沒 preview，解析失敗或內容不對的話原版本已被破壞。沿用兩階段化的 parse_job，但標示「這次解析要覆蓋到既有版本」。

### 10.2 流程

1. 編輯頁加「重新上傳 PDF」按鈕（draft + 無引用才開放）
2. 點按鈕 → 選 parser + PDF → BE `/parse` 帶 `target_version_uid`
3. BE 預檢雙軌守門 + 自動帶入既有版本的 version / release_date / publish_status 到 parse_job
4. FE 跳到匯入頁 Step 2 預覽 + 編輯（基本資料 Tab 隱藏，header 顯示「重新上傳到 vX.Y」）
5. Confirm 時 BE 偵測 `target_version_uid` → 走更新而非新建
6. **中途任何時候可捨棄 / 離開頁面 — 原版本完全沒動**（反悔友善）

### 10.3 Migration

`scripts/sql/2026-05-04-framework-parse-jobs-add-target-version-uid.sql`：
```sql
ALTER TABLE oscal.framework_parse_jobs ADD COLUMN target_version_uid VARCHAR(36) NULL;
CREATE INDEX ... ON oscal.framework_parse_jobs (target_version_uid) WHERE target_version_uid IS NOT NULL;
```

---

## 11. 合規框架版本即時編輯（v1.1 新增）

### 11.1 範圍

匯入後的合規框架版本（draft）若想微調 group / control / assessment 的文字 / 結構，提供獨立的「編輯版本」頁面（無 staging，每個 patch / delete event 即時 PUT/DELETE BE）。

### 11.2 雙軌守門

| 守門條件 | 結果 |
|----------|------|
| `publish_status != 'draft'` | 整頁 read-only（連欄位都不能改） |
| `has_references = true` | `readOnly=false` 但 `allowDelete=false`（只允許改文字、禁 cascade delete） |
| 兩者皆 false | 完整可改可刪 |

### 11.3 Endpoints

| Method | URL | 用途 |
|--------|-----|------|
| GET    | `/oscal-framework-version/<uid>/catalog-tree` | 一次取版本 + groups + controls + assessments + has_references |
| PUT    | `/oscal-catalog-group/<uid>` | patch group 欄位 |
| DELETE | `/oscal-catalog-group/<uid>` | cascade 刪 group + 子 controls + assessments |
| PUT    | `/oscal-catalog-control/<uid>` | patch control 欄位 |
| DELETE | `/oscal-catalog-control/<uid>` | cascade 刪 control + 子 assessments |
| PUT    | `/oscal-catalog-control-assessment/<uid>` | patch assessment 欄位 |
| DELETE | `/oscal-catalog-control-assessment/<uid>` | 刪 assessment |

### 11.4 App Service

`app/oscal/service/framework_version_edit_service.py`：
- `get_catalog_tree(uid)` — 組合 framework_version + 全部 catalog 條目 + `has_references` 判斷
- `update_group / delete_group / update_control / delete_control / update_assessment / delete_assessment` — 每個 method 進來先做 read 守門檢查 → 再寫
- `has_references` 判斷：是否有任何 `compliance.module_frames` / `compliance.profiles` 引用此 version

### 11.5 寫入策略對比

| 流程 | Staging | 寫入時機 |
|------|---------|----------|
| 匯入兩階段 | 有 (parse_job + LocalStorage draft) | confirm 時統一寫 catalog |
| 即時編輯 | 無 | 每個 patch / delete event 即時 PUT/DELETE BE |

選擇即時寫的原因：編輯既有版本通常是小幅微調、不會大量改動、沒有「捨棄」需求；對齊 module_frame default-edit 風格。

---

## 12. file_uid backfill（v1.1 附帶）

### 12.1 背景

合規框架 PDF 匯入兩階段化（v2）後，`oscal_framework_versions.file_uid` 由 jedi-file-upload 的 storage backend 集中管理；舊資料（v1 直接寫流程匯入）只存在 `static/oscal/upload/pdf/<version_uid>.pdf`，DB 沒有 `file_uid`。FE 預覽優先 `file_uid` → fallback legacy 端點。

### 12.2 一次性 backfill

`scripts/python/2026-05-04-backfill-framework-version-file-uid.py`：
- 掃描 `oscal_framework_versions WHERE file_uid IS NULL`
- 對應 `static/oscal/upload/pdf/<version_uid>.pdf` 存在者，呼叫 storage adapter 上傳 → 拿 `upload_files.uid` → `UPDATE framework_versions SET file_uid=...`
- 找不到 archive 的 row 維持 NULL（FE legacy fallback 路徑仍可用）
- **冪等**：只動 file_uid IS NULL 的 row

### 12.3 為什麼繞過 `create_app()`

直接呼叫 `core.app_factory.create_app()` 會觸發 dependency-injector 的 `NonCopyableArgumentError: Couldn't copy keyword argument system_config_domain_service`（DI deepcopy 撞到 bidirectional override，已在 memory `feedback_di_no_bidirectional_override.md` 記錄過類似根因）。

backfill 不需要整個 DI / JWT / Redis / Scheduler，因此只做最小 init：Flask + ConfigUtils + init_db + import jedi_common.session.database.db_mw（載入 RLS hook）+ 直接從 `system_configs` 讀 storage 設定。
