# 合規框架 PDF 匯入兩階段化 — API 規格書（SA）

> **文件版本**：1.1
> **建立日期**：2026-05-03
> **最後更新**：2026-05-04（合併 v2.1 迭代）
> **文件性質**：SA — API 介面契約
> **對應 SD**：[design.md](./design.md)

---

## 0. 迭代紀錄

| 日期 | 版本 | 變更摘要 |
|------|------|---------|
| 2026-05-03 | 1.0 | 初版：parse / detail / confirm / list / delete 五端點 |
| 2026-05-04 | 1.1 | 合併 v2.1 三項迭代：(a) Step 1 parser-only — metadata 三欄改 optional 並可在 confirm 補；(b) 重新上傳 PDF 覆蓋既有版本（parse 帶 `target_version_uid`，confirm 走更新而非新建）；(c) 合規框架版本即時編輯 — 4 個新端點（catalog-tree + group / control / assessment 各 PUT/DELETE）|

---

## 1. 端點總覽

### 1.1 Parse Job 系列（兩階段匯入）

| # | Method | URL | Route Class | 說明 |
|---|--------|-----|-------------|------|
| 1 | GET | `/api/1.0/oscal-framework-parse-jobs` | `FrameworkParseJobListRoute` | 列當前 tenant 進行中草稿（給 banner 用） |
| 2 | POST | `/api/1.0/oscal-framework-parse-jobs/parse` | `FrameworkParseJobParseRoute` | Step 1 — 上傳 PDF + 選 parser；metadata 可選（v1.1 起）；可帶 `target_version_uid` 走覆蓋模式 |
| 3 | GET | `/api/1.0/oscal-framework-parse-jobs/<uid>` | `FrameworkParseJobDetailRoute` | 取單筆 parse_job（含 parsed_result 給預覽） |
| 4 | POST | `/api/1.0/oscal-framework-parse-jobs/<uid>/confirm` | `FrameworkParseJobConfirmRoute` | Step 2 confirm — 套用 decisions/overrides + metadata；新建或覆蓋既有版本 |
| 5 | DELETE | `/api/1.0/oscal-framework-parse-jobs/<uid>` | `FrameworkParseJobDetailRoute` | 軟刪 parse_job（is_active=false） |

### 1.2 Framework Version 即時編輯（v1.1 新增）

| # | Method | URL | Route Class | 說明 |
|---|--------|-----|-------------|------|
| 6 | GET | `/api/1.0/oscal-framework-version/<uid>/catalog-tree` | `FrameworkVersionCatalogTreeRoute` | 取版本 + groups + controls + assessments + has_references（給編輯頁載入） |
| 7 | PUT | `/api/1.0/oscal-catalog-group/<uid>` | `CatalogGroupRoute` | patch group 欄位（draft 限定） |
| 8 | DELETE | `/api/1.0/oscal-catalog-group/<uid>` | `CatalogGroupRoute` | cascade 刪 group + 子 controls + assessments（draft + 無引用） |
| 9 | PUT | `/api/1.0/oscal-catalog-control/<uid>` | `CatalogControlRoute` | patch control 欄位 |
| 10 | DELETE | `/api/1.0/oscal-catalog-control/<uid>` | `CatalogControlRoute` | cascade 刪 control + 子 assessments |
| 11 | PUT | `/api/1.0/oscal-catalog-control-assessment/<uid>` | `CatalogControlAssessmentRoute` | patch assessment 欄位 |
| 12 | DELETE | `/api/1.0/oscal-catalog-control-assessment/<uid>` | `CatalogControlAssessmentRoute` | 刪 assessment |

> 端點命名遵循「複數集合 + 單筆 detail」慣例（見 memory）。Parse Job 沿用 `/api/1.0/oscal-framework-*` prefix；Catalog edit 則使用既有的 OSCAL 單體資源 prefix。

---

## 2. API 端點規格

### 2.1 GET `/api/1.0/oscal-framework-parse-jobs` — 列當前 tenant 草稿

**用途**：FE 進入匯入頁面時取得 banner 用的「未完成草稿」清單。

**認證**：JWT Required

**Query Parameters**（皆可選）：

| 欄位 | 型別 | 說明 |
|------|------|------|
| `status` | string | 過濾 status，不傳預設只列 `awaiting_review` + `failed` |
| `is_mine` | boolean | `true` 只回傳當前使用者建立的；`false`/不傳回全部 tenant 內的 |

**Response**（`FrameworkParseJobListResponseSchema`）：

```json
{
  "status": true,
  "data": [
    {
      "uid": "uuid",
      "status": "awaiting_review|failed",
      "file_name": "string",
      "target_framework_uid": "uuid",
      "target_framework_name": "string",
      "target_version": "string",
      "target_release_date": "YYYY-MM-DD",
      "is_mine": true,
      "created_user": "string",
      "created_user_name": "string",
      "created_at": "ISO8601",
      "updated_at": "ISO8601",
      "summary": {
        "groups": 6,
        "controls": 110,
        "assessments": 320
      }
    }
  ]
}
```

> `summary` 欄位由 BE 對 `parsed_result` 做 count，方便 FE banner 顯示「N 個 groups / M 個 controls」。
> `target_framework_name` 由 BE 對 framework domain service 做 lookup，遵循審計欄位回傳規範（login_name 同時帶 nickname）。

---

### 2.2 POST `/api/1.0/oscal-framework-parse-jobs/parse` — 上傳並解析

**用途**：Step 1 上傳 PDF + 選 parser。同步解析 PDF（不開背景 job），回傳 parse_uid 與 status。Metadata（version / release_date / publish_status）改為可選 — 預設於 Step 2 確認時填寫；若帶 `target_version_uid` 則走「重新上傳到既有版本」覆蓋模式。

**認證**：JWT Required

**Content-Type**：`multipart/form-data`

**Form fields**：

| 欄位 | 型別 | 必填 | 說明 |
|------|------|------|------|
| `file` | binary | Y | PDF binary |
| `json` | string (JSON) | Y | metadata payload，見下表 |

`json` payload 結構（`FrameworkParseRequestSchema`）：

| 欄位 | 型別 | 必填 | 說明 |
|------|------|------|------|
| `target_framework_uid` | string (uuid) | Y | 目標 framework |
| `parser_type` | string | Y | PDF 解析器；現行 dropdown 值 |
| `target_version` | string | N | 目標版本字串（v1.1 改可選；改在 confirm 補） |
| `target_release_date` | string (YYYY-MM-DD) | N | 發行日期（v1.1 改可選） |
| `target_publish_status` | string | N | `draft` / `published` / `deprecated`（v1.1 改可選） |
| `target_parent_version_uid` | string (uuid) | N | 若新增子版本 |
| `target_version_uid` | string (uuid) | N | **v1.1 新增** — 若帶值表示「重新上傳覆蓋既有版本」。BE 預檢：版本存在、屬同 framework、`publish_status='draft'` 且無引用；自動把既有版本的 version / release_date / publish_status 帶進 parse_job 的 target_*（FE 顯示「重新上傳到 vX.Y」badge 用） |

**Response**（`FrameworkParseResponseSchema`）：

```json
{
  "status": true,
  "data": {
    "uid": "uuid",
    "status": "awaiting_review|failed",
    "error_code": null,
    "error_message": null,
    "summary": {
      "groups": 6,
      "controls": 110,
      "assessments": 320
    }
  }
}
```

**注意**：解析失敗時 HTTP 仍回 200，body status 改 `failed` 並帶 error 欄位 — 由 FE 統一導去 detail 頁面再依 status 渲染（見 design 4.2）。

**Error Cases**：

| 條件 | HTTP | error code |
|------|------|----------|
| `target_framework_uid` 不存在 / 跨 tenant | 404 | `GRC_FRAMEWORK_NOT_FOUND` |
| `target_parent_version_uid` 不屬同 framework | 400 | `GRC_FRAMEWORK_PARSE_JOB_INVALID_PARENT` |
| `target_version_uid` 不存在 / 跨 framework | 404 | `GRC_FRAMEWORK_NOT_FOUND` |
| `target_version_uid` 對應版本不是 draft | 403 | `GRC_FRAMEWORK_VERSION_NOT_DRAFT` |
| `target_version_uid` 對應版本已被引用 | 409 | `GRC_FRAMEWORK_VERSION_HAS_REFERENCES` |
| 檔案缺失 / 非 PDF | 400 | `GRC_FRAMEWORK_PARSE_FILE_INVALID` |
| 檔案 > 50 MB | 413 | （Flask MAX_CONTENT_LENGTH） |

> **v1.1 變更**：版本重名檢查（`(target_framework_uid, target_version)` 衝突）從 parse 移到 confirm（因為 parse 階段 version 可能還沒填）。

---

### 2.3 GET `/api/1.0/oscal-framework-parse-jobs/<uid>` — 取單筆

**用途**：Step 2 預覽頁載入；或從草稿 banner 點繼續。

**認證**：JWT Required

**Path Parameters**：`uid`（parse_job uid）

**Response**（`FrameworkParseJobDetailResponseSchema`）：

```json
{
  "status": true,
  "data": {
    "uid": "uuid",
    "status": "awaiting_review|failed|completed|expired",
    "file_name": "string",
    "file_size": 123456,
    "parser_type": "string",
    "target_framework_uid": "uuid",
    "target_framework": {
      "uid": "...", "code": "...", "name": "..."
    },
    "target_version": "string|null",
    "target_release_date": "YYYY-MM-DD|null",
    "target_publish_status": "string|null",
    "target_parent_version_uid": "uuid|null",
    "target_version_uid": "uuid|null",
    "file_uid": "string|null",
    "parsed_result": {
      "catalog": {
        "name": "string",
        "description": "string"
      },
      "groups": [
        {
          "uid": "uuid (parse-time generated)",
          "name": "string",
          "description": "string",
          "order_no": 1,
          "parent_group_uid": "uuid|null"
        }
      ],
      "controls": [
        {
          "uid": "uuid",
          "control_id": "AC.L1-3.1.1",
          "control_title": "string",
          "description": "string",
          "guidance": "string",
          "order_no": 1,
          "group_uid": "uuid"
        }
      ],
      "assessments": [
        {
          "uid": "uuid",
          "name": "string",
          "description": "string",
          "order_no": 1,
          "control_uid": "uuid"
        }
      ]
    },
    "error_code": "string|null",
    "error_message": "string|null",
    "result_framework_version_uid": "uuid|null",
    "is_active": true,
    "created_user": "string",
    "created_user_name": "string",
    "updated_user": "string",
    "updated_user_name": "string",
    "created_at": "ISO8601",
    "updated_at": "ISO8601"
  }
}
```

**Error Cases**：

| 條件 | HTTP | error code |
|------|------|----------|
| uid 不存在或屬其他 tenant | 404 | `GRC_FRAMEWORK_PARSE_JOB_NOT_FOUND` |
| TTL 過期（is_active=false 且 status=expired） | 404 | 同上 |

---

### 2.4 POST `/api/1.0/oscal-framework-parse-jobs/<uid>/confirm` — 確認匯入

**用途**：套用 FE 編輯（decisions + overrides），由 BE 寫入 catalog 系列正式表。依 parse_job 是否帶 `target_version_uid` 分流：
- **新建模式**（`target_version_uid` 為 null）：建立新 framework_version；version 重名檢查在此進行
- **覆蓋模式**（`target_version_uid` 有值）：保留版本 uid 不變、舊 catalog 標 DEPRECATED、新 catalog 接到同 framework_version、更新 file_uid

**認證**：JWT Required

**Path Parameters**：`uid`

**Request Body**（`FrameworkParseJobConfirmRequestSchema`）：

```json
{
  "target_version": "string (v1.1 新增；新建模式必填)",
  "target_release_date": "YYYY-MM-DD (v1.1 新增；新建模式必填)",
  "target_publish_status": "string (v1.1 新增；新建模式必填)",
  "decisions": [
    { "type": "group", "uid": "uuid", "action": "delete" },
    { "type": "control", "uid": "uuid", "action": "delete" },
    { "type": "assessment", "uid": "uuid", "action": "delete" }
  ],
  "overrides": {
    "groups": [
      {
        "uid": "uuid",
        "name": "string|null",
        "description": "string|null",
        "order_no": 1,
        "parent_group_uid": "uuid|null"
      }
    ],
    "controls": [
      {
        "uid": "uuid",
        "control_id": "string|null",
        "control_title": "string|null",
        "description": "string|null",
        "guidance": "string|null",
        "order_no": 1,
        "group_uid": "uuid"
      }
    ],
    "assessments": [
      {
        "uid": "uuid",
        "name": "string|null",
        "description": "string|null",
        "order_no": 1,
        "control_uid": "uuid"
      }
    ]
  }
}
```

**規則**：
- `decisions[].action` 目前僅支援 `delete`（未來若開放新增則加 `create`）
- `overrides[*].uid` 必須對應到 `parsed_result` 內、且未被 decisions 標 `delete` 的條目
- `overrides[*]` 內的字串欄位若為 `null` 表示「不覆蓋」；其他欄位（`order_no` / `parent_group_uid` / `group_uid` / `control_uid`）必為當前實際值或新值
- `parent_group_uid` / `group_uid` / `control_uid` 不可指向被 `delete` 的 uid
- **v1.1 metadata 規則**：若 parse 時已填 metadata 三欄則此處可省略；若 parse 時未填則 confirm 必須補。三欄至少要有一處來源否則丟 `GRC_FRAMEWORK_PARSE_JOB_METADATA_REQUIRED`
- **覆蓋模式下** metadata 三欄忽略（沿用既有版本）

**Response**（`FrameworkParseJobConfirmResponseSchema`）：

```json
{
  "status": true,
  "data": {
    "framework_version_uid": "uuid",
    "framework_uid": "uuid",
    "version": "string",
    "groups_created": 6,
    "controls_created": 108,
    "assessments_created": 312
  }
}
```

**Error Cases**：

| 條件 | HTTP | error code |
|------|------|----------|
| parse_job 不存在 / TTL 過期 | 404 | `GRC_FRAMEWORK_PARSE_JOB_NOT_FOUND` |
| status != `awaiting_review` | 409 | `GRC_FRAMEWORK_PARSE_JOB_ALREADY_CONFIRMED` |
| Metadata 三欄都沒有來源（v1.1）| 400 | `GRC_FRAMEWORK_PARSE_JOB_METADATA_REQUIRED` |
| 套用後剩餘 controls = 0 | 400 | `GRC_FRAMEWORK_PARSE_JOB_EMPTY` |
| 引用已 delete 的 uid | 400 | `GRC_FRAMEWORK_PARSE_JOB_INVALID_REFERENCE` |
| 寫入時 (framework_id, version) 衝突（新建模式）| 409 | `GRC_FRAMEWORK_VERSION_ALREADY_EXISTS` |
| 覆蓋模式下版本不再是 draft / 已被引用（race） | 403 / 409 | `GRC_FRAMEWORK_VERSION_NOT_DRAFT` / `GRC_FRAMEWORK_VERSION_HAS_REFERENCES` |

---

### 2.5 DELETE `/api/1.0/oscal-framework-parse-jobs/<uid>` — 軟刪 / 捨棄

**用途**：使用者點「捨棄草稿」或關閉編輯頁時呼叫。

**認證**：JWT Required

**Response**：`{ "status": true, "data": true }`

**業務邏輯**：將 `is_active=false` / `status='expired'`，**不**真正 delete row。

**Error Cases**：

| 條件 | HTTP | error code |
|------|------|----------|
| parse_job 不存在 | 404 | `GRC_FRAMEWORK_PARSE_JOB_NOT_FOUND` |
| status=`completed`（已成功匯入） | 409 | `GRC_FRAMEWORK_PARSE_JOB_ALREADY_CONFIRMED` |

---

### 2.6 GET `/api/1.0/oscal-framework-version/<uid>/catalog-tree` — 取版本 + 所有 catalog 條目（v1.1 新增）

**用途**：合規框架版本即時編輯頁載入用。一次取版本 metadata + 全部 groups / controls / assessments + 引用狀態（`has_references`）。

**Response**：

```json
{
  "status": true,
  "data": {
    "version_uid": "uuid",
    "version": "string",
    "publish_status": "string",
    "file_uid": "string|null",
    "has_references": false,
    "groups": [ { "uid": "uuid", "name": "...", "description": "...", "order_no": 1, "parent_group_uid": "uuid|null" } ],
    "controls": [ { "uid": "uuid", "control_id": "...", "control_title": "...", "description": "...", "guidance": "...", "order_no": 1, "group_uid": "uuid" } ],
    "assessments": [ { "uid": "uuid", "name": "...", "description": "...", "order_no": 1, "control_uid": "uuid" } ]
  }
}
```

### 2.7 PUT/DELETE `/api/1.0/oscal-catalog-group/<uid>` — Group 單筆編輯（v1.1 新增）

**雙軌守門**：對應 framework_version 必須 `publish_status='draft'` 且 `has_references=false`。

- **PUT body**：`{ name, description, order_no, parent_group_uid }`（皆 optional，為 null 表示不變）
- **DELETE**：cascade 刪 group + 子 controls + 子 assessments；先檢查 has_references 否則 409

### 2.8 PUT/DELETE `/api/1.0/oscal-catalog-control/<uid>` — Control 單筆編輯（v1.1 新增）

- **PUT body**：`{ control_id, control_title, description, guidance, order_no, group_uid }`
- **DELETE**：cascade 刪 control + 子 assessments

### 2.9 PUT/DELETE `/api/1.0/oscal-catalog-control-assessment/<uid>` — Assessment 單筆編輯（v1.1 新增）

- **PUT body**：`{ name, description, order_no, control_uid }`
- **DELETE**：刪 assessment

**§2.7~2.9 共通 Error Cases**：

| 條件 | HTTP | error code |
|------|------|----------|
| 對應版本不是 draft | 403 | `GRC_FRAMEWORK_VERSION_NOT_DRAFT` |
| 對應版本已被引用（DELETE 時） | 409 | `GRC_FRAMEWORK_VERSION_HAS_REFERENCES` |
| uid 不存在 | 404 | `GRC_CATALOG_GROUP_NOT_FOUND` / `GRC_CATALOG_CONTROL_NOT_FOUND` / `GRC_CATALOG_CONTROL_ASSESSMENT_NOT_FOUND`（沿用既有） |

---

## 3. 新增 Error Code

```python
# common/code/grc_error_code.py — Framework PDF Import 段
GRC_FRAMEWORK_PARSE_FILE_INVALID            = ("上傳檔案無效，請上傳 PDF 檔案",                       "GRC_400020")
GRC_FRAMEWORK_PARSE_JOB_EMPTY               = ("解析結果為空，無控制項可匯入",                        "GRC_400017")
GRC_FRAMEWORK_PARSE_JOB_INVALID_PARENT      = ("Parent version 無效或跨 framework",                   "GRC_400018")
GRC_FRAMEWORK_PARSE_JOB_INVALID_REFERENCE   = ("Override 引用了被刪除的條目",                         "GRC_400019")
GRC_FRAMEWORK_PARSE_JOB_METADATA_REQUIRED   = ("確認匯入需指定版本 / 發布日期 / 發布狀態",            "GRC_400021")  # v1.1
GRC_FRAMEWORK_NOT_FOUND                     = ("合規框架不存在",                                      "GRC_404026")
GRC_FRAMEWORK_PARSE_JOB_NOT_FOUND           = ("框架解析任務不存在或已過期",                          "GRC_404025")
GRC_FRAMEWORK_VERSION_ALREADY_EXISTS        = ("框架版本已存在（同 framework 下 version 重複）",       "GRC_409006")
GRC_FRAMEWORK_PARSE_JOB_ALREADY_CONFIRMED   = ("解析任務已被確認匯入或已捨棄",                        "GRC_412015")
GRC_FRAMEWORK_PARSE_FAILED                  = ("框架 PDF 解析失敗",                                  "GRC_422004")

# Framework Version 即時編輯（v1.1 新增）
GRC_FRAMEWORK_VERSION_NOT_DRAFT             = ("僅 draft 狀態的版本可編輯",                           "GRC_403030")
GRC_FRAMEWORK_VERSION_HAS_REFERENCES        = ("版本已被合規資源庫引用，禁止刪除控制項 / 群組 / 評估目標", "GRC_409010")
```

> 序號接續既有 grc_error_code.py 末段；422 採 SSP doc parser 同模式（FATAL parse error 用 422 區）。

---

## 4. 權限 / 認證

- 所有端點僅檢查 JWT，不額外加角色檢查（RBAC 由 `route_capabilities` 配置決定誰可呼叫）
- 新路由 `/compliance-framework/import-version` 需在 `ui_routes` 註冊；對應的 BE capabilities 需新增至 `route_capabilities`（詳見 `docs/system-design/permission/...` 的 SOP）
