# SSP 重新匯入差異審查 設計說明書

> **文件版本**：1.2
> **撰寫日期**：2026-05-06（初版）；**最後修訂**：2026-05-07（review 後對齊）
> **文件性質**：SD — System Design（架構與實作策略）
> **Raw requirement**：`./raw-requirement.md`
> **狀態**：已實作完成（BE 35 + FE 60 + Test 1 = 96 commits）；code review (Critical 6 / Important 12) 已修；§11 記錄所有與設計的偏差（含 review 後新增 §11.13–11.16）。
>
> **⚠️ 閱讀注意**：本文件保留原始 brainstorm 設計（§1–§10）作為歷史脈絡。實作中有若干修正，各節內文已加 `(updated 2026-05-07: see §11.x)` 標記，詳細偏差原因請見 §11。
>
> **設計守則：**
> 1. **不加新表、不改既有 endpoint URL**，只擴充 parse / confirm 的 request/response schema
> 2. 不修改 jedi-oscal 套件，新邏輯落在主專案
> 3. 沿用既有 `SspDocxImportPage.vue` 元件結構，Step 2 為新插入步驟、Step 3 為既有元件微改
> 4. 嚴守 CLAUDE.md DDD 層級規範（Route 不查 DB / App Service `@transaction` / Repo session lazy / 權限檢查在 app service 層）
> 5. **Smart default 邏輯落在 BE**，FE 只渲染 + 收集 user 決策

---

## 0. 問題背景

**痛點**：使用者重新上傳 SSP 文件時，目前 import-docx UI 只顯示「分析完的結果」（parsed value），看不到「DB 現值」（current value）。如果之前手動編輯或刪減過內容，按下確認等於無提示覆蓋，造成「不小心整個覆蓋過去」的風險。

**目標**：新增 Step 2 Diff 解決頁，逐項顯示「現值 vs 新值」並讓使用者決策（保留 / 採用新值 / 跳過），預設保守（有差異時 default 不覆蓋），最後 confirm 時只寫入勾選為 `use_docx` 的項目。

---

## 1. 整體流程

```
[Step 1] 上傳 docx
   │
   ▼
[POST /ssp-docx-imports/parse]  ← 既有 endpoint，擴充 update mode 邏輯
   │   parse + match controls + load DB current state（含 current_parties 新增）
   │   compute smart default per item（§2.5 邏輯序列化進 response）
   │   filter 完全相同的項目（不出現在 diff 範圍）
   │   return { has_diff, diff_summary, sections: {controls, objectives, parties} }
   │
   ▼
┌─ has_diff == false (create mode 或全部相同) ──┐
│                                                │
└─ has_diff == true                              │
     │                                           │
     ▼                                           │
[Step 2  Diff 解決頁 ← 新做]                     │
   3 sections（控制條文 / 目標 / Parties），每 section 有：
   • 篩選器 [全部 / 已修改 / 新增 / 待決策]（預設「待決策」）
   • 批次按鈕 [全部採用新值] / [全部保留現值]（含防呆 confirm dialog）
   • 條目卡：side-by-side 並排（左=DB 現值、右=docx 新值），紅綠 word-level highlight
   • 決策 pills: [保留現值 / 採用新值 / 跳過]
     │                                           │
     ▼                                           │
[Step 3  內容微調頁 ← 既有 UI 微改] ◄────────────┘
   接收 diff 決議結果：
   • action=skip → 該項不顯示
   • action=keep_current → 顯示但唯讀（鎖 inline edit input）
   • action=use_docx → 顯示且可 inline 編輯（同現有）
   │
   ▼
[POST /ssp-docx-import/<uid>/confirm]  ← 既有 endpoint，extends decisions schema
   payload: { decisions, parties_decisions, content_overrides }
   only items with action=use_docx are written
```

**核心原則：**
- 既有 parse + confirm endpoint **簽名不變**，schema 擴充
- Step 2 是新頁；Step 3 是既有頁微改（接收 decisions 過濾）
- 「DB 有但 docx 沒帶」自然不出現在 diff 頁，原樣保留
- 完全相同的項目也不出現在 diff 頁

---

## 2. Diff 範圍

依使用者確認，diff 涵蓋當前 import-docx 頁面**可編輯的 3 大類**：

| 類別 | 內容 | 既有 BE response 是否含 current | Diff feature 改動 |
|------|------|------------------------------|------------------|
| **1. 控制條文** | 每條 matched_control 的 `implementation_description` | ✅ `current_implementation_description` | FE 渲染 + 加 default_action 序列化 |
| **2. 目標 (a/b/c)** | 每條 control 下的 `objectives[].description` | ✅ `current_objectives_descriptions` map | 同上 |
| **3. Parties** | 對應人員 / 組織（name / role / email / matched_user_id / matched_org_unit_id） | ❌ 目前無 current_parties 概念 | **BE 新增 `_load_current_parties()`** |

**不在範圍**：metadata（name / version / group / provider / description / frequency）— SSP 重新上傳場景中此類欄位很少由 docx 帶入，且使用者明確排除。

---

## 3. Smart Default 邏輯（§2.5 序列化）

針對每個 diff 項目，BE 依 `current` vs `parsed` 內容狀態，預先計算 `default_action` + `diff_status` 並回傳。

> **(updated 2026-05-07: see §11.1, §11.2, §11.3)** — 原始設計中 `added` 預設 `use_docx`、`unchanged`/`gone` 過濾掉。實作後全部改動：見下表「實際行為」欄。

**原始設計（Phase 1 brainstorm）：**

| `current` 狀態 | `parsed` 狀態 | `diff_status` | `default_action` | 是否出現在 diff response |
|--------------|--------------|--------------|-----------------|----------------------|
| 空 / null | 有值 | `"added"` | `"use_docx"` | ✅ 顯示，預設採用 |
| 有值 | 跟 current 完全相同（`.strip()` 後） | `"unchanged"` | n/a | ❌ filter 掉 |
| 有值 | 跟 current 有差異 | `"changed"` | `"keep_current"` | ✅ 顯示，預設保留 |
| 有值 | 不在這次 docx 中 | `"gone"` | n/a | ❌ filter 掉（silent preserve） |

**實際行為（shipped v1）：**

| `current` 狀態 | `parsed` 狀態 | `diff_status` | `default_action` | 是否在 BE response |
|--------------|--------------|--------------|-----------------|-------------------|
| 空 / null | 有值 | `"added"` | `null`（user 必選） | ✅ 顯示 |
| 有值 | 跟 current 完全相同 | `"unchanged"` | `null` | ✅ BE 保留（Step 3 需要全部資料） |
| 有值 | 跟 current 有差異 | `"changed"` | `"keep_current"` | ✅ 顯示，預設保留 |
| 有值 | 不在這次 docx 中 | `"gone"` | `"keep_current"`（user 可改 `use_docx` 來清空） | ✅ 顯示 |

**FE 責任分攤**：BE annotate 保留所有項目（不做 filter）；**Step 2 FE 過濾 `unchanged`** 不顯示；Step 3 使用完整清單（含 unchanged）以正確呈現預覽。

**邏輯位置**：`app/oscal/service/ssp_docx_diff_service.py`（純邏輯類）+ `ssp_docx_import_app_service.py` 內調用。

---

## 4. Data Model & BE 變更

### 4.1 Parse Response 擴充（GET `/ssp-docx-import/<parse_uid>`）

> **(updated 2026-05-07: see §11.2)** — BE annotate 保留**所有**項目（`diff_status` 不限），不預先 filter。FE Step 2 按 `diff_status` filter 顯示；Step 3 使用完整清單。
>
> **(updated 2026-05-07)** — `mode` 欄位值為 `"full"` / `"statement_only"`（代表 parse mode），**不是** `"create"` / `"update"`。FE 偵測「是否需 Step 2」依 `diff_summary.controls.changed > 0` 或其他 section changed/added/gone > 0 判斷。

```jsonc
{
  "parse_uid": "...",
  "mode": "full",                                                     // 既有（parse mode: full/statement_only）
  "has_diff": true,                                                   // 新增
  "diff_summary": {                                                   // 新增
    "controls":   { "changed": 3, "added": 1, "total_with_diff": 4 },
    "objectives": { "changed": 5, "added": 2, "total_with_diff": 7 },
    "parties":    { "changed": 1, "added": 2, "total_with_diff": 3 }
  },

  "matched_controls": [
    {
      "control_id": "AC-2",
      "current_implementation_description": "系統採用 RBAC...",     // 既有
      "parsed_implementation_description":  "系統採用 ABAC...",     // 既有
      "default_action": "keep_current",                              // 新增
      "diff_status":    "changed",                                   // 新增
      "objectives": [
        {
          "objective_key":      "(a)",
          "current_description": "...",
          "parsed_description":  "...",
          "default_action":     "keep_current",                      // 新增
          "diff_status":        "changed"                            // 新增
        }
      ]
    }
  ],

  "parties": [
    {
      "party_uid":    "...",                                         // matched 時 = current 的既有 uid；added 時 = BE synthesize 的暫時 uid（FE 用於 v-for key + confirm payload reference）
      "party_type":   "person",
      "current_values": {                                            // 新增
        "name": "王小明",
        "email": "ming@old.com",
        "role": "系統管理員",
        "matched_user_id": 42,
        "matched_org_unit_id": null
      },
      "parsed_values": {                                             // 新增
        "name": "王小明",
        "email": "ming@new.com",
        "role": "系統管理員",
        "matched_user_id": 42,
        "matched_org_unit_id": null
      },
      "default_action": "keep_current",                              // 新增
      "diff_status":    "changed"                                    // 新增
    }
  ]
}
```

### 4.2 Confirm Payload 擴充（POST `/ssp-docx-import/<parse_uid>/confirm`）

**`action` 三值語意**（沿用既有 ssp-doc-parser）：
- `"use_docx"` — 採用新值寫入 DB
- `"keep_current"` — 保留現值（DB 不動）
- `"skip"` — 跳過此項（DB 不動，且編輯頁不顯示）

**對 `diff_status="added"` 項目（current 為空）：**
- `keep_current` 跟 `skip` 行為等價（兩者都不寫入），FE 可只渲染 `[採用新值 / 跳過]` 兩按鈕，BE 收到任一視為不寫入即可


```jsonc
{
  "decisions": [
    {
      "control_id": "AC-2",
      "action": "use_docx",                                          // 既有
      "objectives": [
        { "objective_key": "(a)", "action": "keep_current" }         // 既有
      ]
    }
  ],
  "parties_decisions": [                                             // 新增
    { "party_uid": "...", "action": "use_docx" }
  ],
  "content_overrides": { ... }                                       // 既有
}
```

### 4.3 BE Service 層變更

**檔案：`app/oscal/service/ssp_docx_import_app_service.py`**

| 方法 | 變更 |
|------|------|
| `_load_candidates()` | 既有，控制條文 + 目標 already 載 current。**新增載 current_parties** |
| `_load_current_parties(ssp_uid)` | **新方法** — 透過 `responsible_party_domain_service` 查 SSP 既有 parties |
| `_compute_default_actions(parse_result)` | **新方法** — 依 §3 smart default 邏輯算每項 default_action + diff_status，filter 掉 unchanged / gone |
| `_match_parties(current_parties, parsed_parties)` | **新方法** — 用 (name+email) 模糊比對配對 current ↔ parsed，無對應的視為兩筆獨立 |
| `_build_diff_summary(parse_result)` | **新方法** — 統計各 section 的 changed / added / total_with_diff |
| `confirm_import()` | **改** — 接收 `parties_decisions`，filter parties by `action=use_docx` 才寫 |

### 4.4 Strategy 層變更

> **重要修正**：`SspWriteStrategy.write_parties()` 已存在於 `ssp_write_strategy.py:282`（含 `_upsert_party()` / `_upsert_responsible_party()` helpers），`ModuleFrameWriteStrategy.write_parties()` 也已存在。本 feature 不需新增 strategy method，**只需在 app service `confirm_import()` 內依 `parties_decisions` filter 後再呼叫既有 `write_parties()`**。

> **(updated 2026-05-07: see §11.5)** — `ParsedParty` 新增 `target_party_uid` 欄位，讓 strategy 能用精準 uid 更新已比對到的 party（而非靠 name 查找 — name 有可能已改，造成 append 新 party 的 bug）。
>
> **(updated 2026-05-07: see §11.12)** — `_upsert_responsible_party()` 對已存在的 link record（同 party_uid + role + context 組合）採 idempotent return（`ResponsiblePartyEntity` 無 `uid` 屬性，update path 無法成立）。

| Strategy | 變更 |
|---------|------|
| `SspWriteStrategy.write_parties()` | **既有，不改動**。Caller (app service) 負責 filter by action 後傳入 |
| `SspWriteStrategy._upsert_party()` | **改動**：優先用 `target_party_uid`（若有）查找，避免 name 改變造成 append |
| `ModuleFrameWriteStrategy.write_parties()` | 既有，不改動 |
| `SspWriteStrategy.write()` / `write_objectives()` | 既有，行為不變 |

**呼叫順序**（app service confirm 內）：
```python
filtered_controls = filter_by_decisions(parsed_controls, decisions)
strategy.write(filtered_controls, content_overrides)        # 既有

filtered_parties = filter_by_parties_decisions(parsed_parties, parties_decisions)
strategy.write_parties(filtered_parties, source_uid, user_id)  # 既有
```

### 4.5 Domain / Infra 層變更

- `domain/grc/responsible_party_domain_service.py` — 若無 `list_by_ssp(ssp_id)`，新增；若無此 domain service，新建（含 repo + interface）
- `infra/grc/repository/responsible_party_repo_impl.py` — 同上，視既有狀態決定是否新建

> **(updated 2026-05-07: see §11.12)** — 實作中確認沿用 `BaseRepoImpl.list_by(QueryEntity)` standard pattern，不需新增 domain service 層。`_load_current_parties` 內直接用 `responsible_party_domain_service.list_by()` 拿到 link records 後，join `PartyEntity`（via `party_uuid`）取 name / email 等欄位。

### 4.6a Email Field Aliasing（新增 2026-05-07）

> **(see §11.7)**

DB current parties dict 使用 `email` 欄位名；parsed parties（from docx parser）使用 `email_address`。`ssp_docx_diff_service.py` 的 `_get_party_field()` helper 統一 normalize 兩個 key name，確保 diff 比對不因欄位名差異誤判。

### 4.6b Party-type-aware Diff（新增 2026-05-07）

> **(see §11.8)**

原設計對所有 party 統一比對 5 個欄位（`name` / `email` / `role` / `matched_user_id` / `matched_org_unit_id`）。實作後改為 party_type-aware：
- `person`：不比 `matched_org_unit_id`
- `organization`：不比 `matched_user_id`

避免 person 因 org_unit_id 為 null / organization 因 user_id 為 null 被誤判為有 diff。

### 4.6c DB-only Objectives 合成（新增 2026-05-07）

> **(see §11.9)**

當 DB 內某條 control 有 N 個 AO，但 docx 這次只帶 M 個（M < N），多出的 N-M 個 AO 不會出現在 parsed result 中，導致 diff 頁漏掉它們。BE 在 annotate 之前新增 `_inject_current_objective_descriptions()` pass：遍歷 DB objectives，若某個 `objective_key` 在 parsed result 中不存在，合成一筆 `parsed_description=""` → 使其出現為 `diff_status="gone"`。

### 4.6d Party Enrichment（新增 2026-05-07）

> **(see §11.6)**

原設計 parties response 只帶 `matched_user_id`（整數 ID）。實作中發現這是資安問題（暴露內部 ID）且 FE 無法顯示對人名。BE 新增 `_enrich_party_user_org_names()` 批次查詢：
- 對 `matched_user_id` → 填入 `matched_user_nickname` / `matched_user_login_name`
- 對 `matched_org_unit_id` → 填入 `matched_org_unit_name`

current parties 和 parsed parties 都會做 enrichment。FE 顯示 `✓ 已連結 ${nickname} (${login_name})` 而非 `(user 17)`。

### 4.6e Module Frame Source Type Support（新增 2026-05-07）

> **(see §11.11)**

原設計只考慮 `project_ssp` source type。實際上 module_frame import 是主要 user flow。`_load_current_parties` 擴充：當 `source_type = 'module_frame'` 時，查詢 `responsible_party` with `context_type='module_frame'` 和對應 `context_id`，而非走 SSP 路徑。

### 4.8 Backward Compat

- **Create mode**：parse response 的 `current_*` 全空、`has_diff=false`、不顯示 Step 2 → 既有 create flow 完全不變
- **既有 module-frame import 入口**：FE 條件式 render Parties section（依 source_type）；BE 在 module-frame 模式下 parties decisions 為空陣列即可

---

## 5. FE 元件結構

> **(updated 2026-05-07: see §11.4, §11.10)** — Filter 從 5 選項簡化為 3；「已決策」語意為互斥；bulkApply cascade 到 AOs；Steps 用 `activeStep` prop；`responsible-organization` 不再隱藏。詳見各 updated 標記。

### 5.1 元件樹

```
SspDocxImportPage.vue (既有 wrapper，加 stepper progression)
├─ ImportOutline.vue (既有，加 diff_summary 顯示)
├─ Step 1: UploadStep.vue (既有 upload UI)
│
├─ Step 2: DiffResolutionStep.vue ← 【新】
│   │
│   ├─ DiffSectionShell.vue (shared header) ← 【新】
│   │   • title + filter [全部 / 待決策 / 已決策]（原設計 5 選項，改為 3；updated 2026-05-07: see §11.4）
│   │   • 批次按鈕 [全部採用新值] / [全部保留現值]（含防呆 dialog；bulkApply cascade 到所有 AOs）
│   │   • collapse/expand 該 section
│   │
│   ├─ ControlDiffSection.vue ← 【新】
│   │   • 套 DiffSectionShell
│   │   • for each matched_control with diff_status≠"unchanged"（FE filter，BE 保留全部）
│   │       └─ ControlDiffCard.vue ← 【新】
│   │           • side-by-side 並排（current vs parsed）
│   │           • word-level highlight（FE 用 jsdiff lib 算）
│   │           • 決策 pills [保留現值 / 採用新值 / 跳過]
│   │           • 巢狀 ObjectiveDiffRow.vue（每個 (a)/(b)/(c)）
│   │
│   └─ PartiesDiffSection.vue ← 【新】
│       • 套 DiffSectionShell
│       • for each party with diff_status≠"unchanged"
│           └─ PartyDiffCard.vue ← 【新】
│               • side-by-side（current_values vs parsed_values）
│               • field-level highlight（name/role/email/matched_user）
│               • 決策 pills（"added" 項目沒「保留現值」，只有採用/跳過）
│               • 顯示所有 role 包含 responsible-organization（updated 2026-05-07: see §11.4）
│
├─ Step 3: ContentAdjustmentStep.vue (既有 ControlImplSection + PartiesSection 重構)
│   • 接收 decisions，filter rendering：
│       - action=skip → 不顯示
│       - action=keep_current → 顯示，值取 current_description（原設計為 readonly；實作改為 inline edit 仍開放，決策決定「寫什麼」而非「能否編輯」）
│       - action=use_docx → 顯示，值取 parsed_description；可 inline 編輯
│       - action=unchanged / null → 顯示原值
│   • 「上一步」保留 decisions 狀態，回 Step 2（若有 diff）或 Step 1
│
└─ Step 4: ConfirmStep.vue (既有)
```

### 5.2 共用：Word-level Diff 計算

- **Library**：`diff` (jsdiff，輕量、ESM、無 dep)
- **建議方法**：`diffWords` / `diffWordsWithSpace`（中英文混排夠用）
- **位置**：`src/utils/textDiff.js`，輸出 `[{value, added, removed}]`，`ControlDiffCard` 跟 `PartyDiffCard` 共用
- **設計理由**：BE 只送 raw current + parsed，視覺層由 FE 處理 — 保持資料層乾淨、不把 UI 感知洩漏到 API

### 5.3 State Management — 新建 Pinia Store

**`src/stores/ssp-docx-import.js`**

> **(updated 2026-05-07: see §11.4)** — `mode` state 意義改變：`mode` 是 parse mode（`full` / `statement_only`），不再是 `'create'` / `'update'`。FE 改用 `hasDiff`（computed from `diff_summary`）決定是否需要 Step 2。

```js
state: {
  parseUid: null,
  hasDiff: false,                         // computed from diff_summary（updated 2026-05-07）
  parseResult: { ... },                    // 完整 parse response
  decisions: {
    controls: { [control_id]: { action, objectives: { [key]: action } } },
    parties:  { [party_uid]: { action } }
  },
  contentOverrides: { ... }                // inline 編輯結果（同既有）
}

actions: {
  loadParseResult(uid),
  initDefaultDecisions(),                  // 從 parse_result.default_action 初始化
  setControlDecision(control_id, action),
  setObjectiveDecision(control_id, key, action),
  setPartyDecision(party_uid, action),
  bulkApply(section, action),              // 批次操作
  filterDiffItems(section, filter),        // 篩選器
  submit()                                 // 組 confirm payload，POST
}
```

**為何用 Pinia**：decisions 跨 Step 2 跟 Step 3 元件樹，需要全局狀態。既有 SspDocxImportPage 內 ref 不夠用。

### 5.4 Routing

```
/module-frame/:uid/import-docx       (既有，create / update mode 用)
/ssp/:uid/import-docx                (既有，update mode 用)
```

**Stepper 內部頁切換用 v-if 而非 vue-router 子路由** — 共用 Pinia state、避免 route 切換 reset state。

### 5.5 既有 Step 3 元件改動範圍

- `ControlImplSection.vue`：新增 prop `decisions`，v-for 過濾 `action !== 'skip'`，v-if `action === 'keep_current'` → input 改 `readonly` + badge。**約 30 行**
- `PartiesSection.vue`：同模式，依 `parties_decisions` 過濾跟鎖定。**約 20 行**
- `MetadataSection.vue`：完全不動

### 5.6 視覺風格定案

依 brainstorming visual 階段確認：

- **控制條文 / 目標 / Parties 一致採用 side-by-side 並排**（左 DB 現值、右 docx 新值）
- **紅綠 word-level highlight**（紅底劃線=刪除、綠底=新增）
- **採用既有 PrimeVue 元件**：`Stepper` / `InputText` / `Button` / `Tag` / `Dropdown`
- **配色用 design tokens**（避免硬編 hex），跟既有 design system 一致

---

## 6. 邊界情境 & 錯誤處理

### 6.1 Edge Cases

| 情境 | 處理 |
|------|------|
| **Parse 過期**（>1hr） | Step 2/3 操作時 BE 回 404 → FE 顯示 dialog「Parse 已過期，請重新上傳」→ 導回 Step 1 |
| **All decisions = skip** | Confirm 前防呆 dialog「您沒有勾選任何要寫入的項目，繼續會等於沒匯入」→ 確認才送出 |
| **Browser refresh** | Decisions 在 Pinia 是 in-memory，refresh reset。**v1 不做 persistence**（決策快、refresh 重來成本低）。未來可考慮 localStorage 持久化 by parse_uid |
| **用戶在另分頁手動改 DB**（current 已變動） | Diff 頁 current 是 parse 當時 snapshot，不重新拉。**接受 stale**；confirm 用 `@transaction` 保證寫入一致性 |
| **Unmatched paragraphs**（docx 段落沒對到任何控制項） | 不算 diff 範圍，沿用既有 `UnmatchedSection` 拖拉指派處理 |
| **同一個 SSP 兩個 user 同時 parse** | 各自獨立 parse_uid，互不干擾；最後 confirm 覆蓋先 confirm。**v1 不做樂觀鎖** |
| **篩選器選「待決策」但已無待決項目** | 顯示空狀態：「太棒了！所有差異都已決策完成」+ 「下一步」按鈕 |
| **某 section 沒任何 diff** | Section header collapsed + badge「無差異」 |
| **`current_parties` vs `parsed_parties` 無共同 party_uid** | Match 邏輯：(name+email) 模糊比對；比不到視為兩筆獨立（`gone` 顯示為 keep_current；`added` 顯示，user 必選；updated 2026-05-07: see §11.3） |

**新增 edge cases（updated 2026-05-07: see §11.10）：**

| 情境 | 處理 |
|------|------|
| **PrimeVue `Steps` prop 名稱** | PrimeVue 3.53 用 `activeStep`，舊文件寫 `activeIndex`（見 §11.10） |
| **`SelectButton` 點已選選項 → null** | 需加 `unselectable="false"` prop，否則點同一按鈕回傳 null，破壞決策狀態 |
| **`Dropdown` value=null 顯示 placeholder** | PrimeVue 3.x null value 不匹配任何 option，顯示 placeholder 而非 option label。用 sentinel 字串（如 `"__inherit__"`）替代，存檔前轉換回 null |
| **Mode 偵測** | BE response `mode` 值是 `full`/`statement_only`（parse mode），不是 create/update。FE 改用 `diff_summary.*.changed > 0 || added > 0 || gone > 0` 判斷是否顯示 Step 2 |

### 6.2 錯誤處理（CLAUDE.md error code 規範）

**沿用既有 ssp-doc-parser 已定義之 error code（避免重複）：**

| Code | 用途 | 來源 |
|------|------|------|
| `GRC_DOCX_PARSE_JOB_NOT_FOUND` (GRC_404022) | Parse 任務不存在或已過期 | ssp-doc-parser §5 |

**本 feature 新增：**

```python
GRC_SSP_PARSE_ALREADY_CONFIRMED  = ("此 Parse 已 confirm，請重新上傳", "GRC_409XXX")
GRC_SSP_DECISIONS_INVALID        = ("Decisions 包含未知 control_id",   "GRC_400XXX")
GRC_SSP_PARTIES_DECISION_INVALID = ("Parties decisions 包含未知 party_uid", "GRC_400XXX")
```

具體序號於 implementation plan 階段依當時最新 GRC error code 序列分配。

### 6.3 寫入交易性

- Confirm 用既有 `@transaction` decorator → 全有或全無
- 三層寫入順序（write strategy 內）：`write_controls()` → `write_parties()`（新）→ （metadata 不在範圍）
- 任一步 raise → 整個 transaction rollback、parse_job 不標 confirmed → user 修正後可重試

### 6.4 權限檢查

依既有 import-docx 權限：parse / confirm 必須 manager 角色（在 SSP 所屬 project）。
- **檢查層級**：app service 層透過 `participant_domain_service.get_one()` → 確認 role=manager
- 不在 route 層查 DB（CLAUDE.md DDD 規範）

### 6.5 Diff 計算的失敗模式

- jsdiff 對極長文本（>100KB）算 word-diff 可能慢 → 加 fallback：超過閾值切回「整段顯示，不做 word highlight」+ console warn
- BE filter 完全相同的項目時，文本比對用 `.strip()` 後完全相等才算「相同」（避免空白差異被誤判 diff）

---

## 7. 測試策略

### 7.1 BE Unit Tests（`compliance-manager-be/test/`）

| 測試檔 | 範圍 |
|-------|------|
| `test_ssp_docx_import_smart_default.py` | `_compute_default_actions()` — 4 case：current 空 / 完全相同 / 有差異 / current 有 docx 沒 |
| `test_ssp_docx_import_diff_summary.py` | `_build_diff_summary()` 統計正確性 |
| `test_ssp_docx_import_current_parties.py` | `_load_current_parties()` 從 DB 撈現有 parties |
| `test_ssp_write_strategy_parties.py` | `write_parties()` filter by action、寫入 `responsible_party` |

### 7.2 BE Integration Tests（`test/integration/`）

| 測試檔 | 範圍 |
|-------|------|
| `test_ssp_docx_import_diff_flow.py` | parse update mode → 驗 response 含 has_diff/default_action → confirm 帶混合 decisions → 驗 DB 只寫 use_docx 項目 |
| `test_ssp_docx_import_create_no_diff.py` | create 模式 has_diff=false → confirm 全寫入（regression test） |
| `test_ssp_docx_import_decisions_validation.py` | Decisions 帶未知 control_id / party_uid → 400 with 對應 error code |

### 7.3 FE Component Tests（`compliance-manager-fe/`）

| 測試 | 範圍 |
|------|------|
| `ControlDiffCard.spec.js` | 給定 current + parsed → 渲染 side-by-side + word-level highlight 正確 |
| `PartiesDiffSection.spec.js` | 篩選器 / 批次按鈕 / decisions 更新 |
| `ssp-docx-import.store.spec.js` | Pinia store actions：initDefaultDecisions / bulkApply / submit payload 組裝 |
| `textDiff.spec.js` | jsdiff 包裝層：中英混排、極長字串 fallback |

### 7.4 E2E（`compliance-manager-test/`）

依 CLAUDE.md「測試專案路徑」，**在獨立 repo 用 Cucumber + Playwright**：

```gherkin
Feature: SSP 重新上傳顯示 diff
  Background:
    Given 我已登入為 manager
    And 該 SSP 已存在控制條文 AC-2 (內容: "RBAC機制") 跟 parties [王小明 ming@old.com]

  Scenario: 偵測差異並進入 Diff 頁
    When 我重新上傳含 AC-2 (內容: "ABAC機制") + 王小明 ming@new.com 的 docx
    Then 我會看到 Step 2 Diff 解決頁
    And AC-2 卡片顯示 side-by-side，"RBAC" 紅底劃線、"ABAC" 綠底
    And 王小明 卡片顯示 email 從 ming@old.com → ming@new.com

  Scenario: 預設 keep_current，主動切換才覆蓋
    Given 我在 Diff 頁
    When 我把 AC-2 切到「採用新值」
    And 王小明 維持「保留現值」（預設）
    And 點下一步 → 編輯頁 → 確認匯入
    Then DB 中 AC-2 是 "ABAC機制"
    And DB 中王小明 email 仍是 ming@old.com

  Scenario: 批次套用全部採用新值
    Given Diff 頁有 5 條控制差異
    When 我點「全部採用新值」並確認 dialog
    Then 5 條都切到「採用新值」狀態

  Scenario: Create 模式跳過 Diff 頁
    Given 該 SSP 是空的
    When 我上傳 docx
    Then 我不會看到 Step 2 Diff 解決頁
    And 我直接進到 Step 3 編輯頁
```

**新增檔案位置**（依 compliance-manager-test 既有結構）：
- `requirements/ssp-update-diff.md`
- `specs/ssp-update-diff.feature`
- `features/steps/ssp-update-diff.steps.js`
- `features/pages/SspDocxImportDiffPage.js`

### 7.5 測試覆蓋目標

| 層 | 覆蓋率 |
|----|--------|
| BE 新方法（`_compute_default_actions` / `_load_current_parties` / `write_parties`） | ≥ 90% |
| FE 新元件 | smoke test（render OK + key interactions） |
| E2E | 上述 4 條 scenario |

### 7.6 Test Plan 階段

依 CLAUDE.md feature-development-workflow Phase 4，implementation plan 落地後**用 `feature-test-planner` agent** 產 `test-plan.md`，含 BE pytest + FE Vitest + E2E BDD 完整 case list 跟 traceability matrix。本段只列方向。

---

## 8. 開發順序建議（草擬，待 implementation plan 細化）

1. **BE — diff response & smart default**（不改 confirm）
   - 新增 `_load_current_parties` / `_compute_default_actions` / `_build_diff_summary` / `_match_parties`
   - 擴充 parse response schema（保持 backward compat — 既有欄位不動）
   - 單元測試
2. **BE — confirm payload 擴充**
   - 新增 `parties_decisions` 處理
   - `SspWriteStrategy.write_parties()` 實作
   - 整合測試
3. **FE — Pinia store + Step 2 元件**
   - `ssp-docx-import.js` store
   - `DiffResolutionStep` + 6 個新元件 + `textDiff.js` util
   - Component tests
4. **FE — Step 3 既有元件 readonly 模式**
   - `ControlImplSection` / `PartiesSection` 接收 decisions
5. **E2E**（在 compliance-manager-test repo）
6. **整合測試 + UAT**

---

## 9. Brainstorming 決策紀錄（追溯用）

| 決策點 | 結論 | 原因 |
|-------|------|------|
| Diff 視覺風格 | A. Side-by-side 並排（左舊右新、紅綠 word highlight） | 長段落最直觀，符合「看得清楚 vs 過去」的心智模型 |
| Diff 範圍 | 1. 控制條文 + 2. 目標 + 4. Parties（不含 metadata） | 使用者確認當前頁面可編輯就這 3 類，metadata 不在 SSP 重新上傳場景 |
| 預設動作 | C. Smart default — 空→use_docx, 同→不顯示, 異→keep_current | 兼顧首次匯入便利性 + 後續更新安全性 |
| 流程模式 | 模式 1 — 兩階段獨立頁（Diff 頁 → 編輯頁） | 強制 user 先審 diff 最防呆，既有編輯頁不大改 |
| Diff 頁出現時機 | 只 update 模式 | Create 模式 current 全空、無比對基準 |
| Parties 排版 | Layout 1 — 每人一張卡 + side-by-side | 跟控制條文視覺一致 |
| 批次操作 | D. 全部套用按鈕 + 篩選器 | 100+ 條 diff 場景下省力，全部套用加防呆 dialog |
| 「DB 有但 docx 沒」處理 | 不顯示、原樣保留 | 從 flow 自然推出（無 diff），不增加複雜度；user 想清理應走編輯流程而非匯入 |

---

## 10. 開放議題（implementation plan 階段需釐清）

> **(updated 2026-05-07)** — 以下議題均已在實作中解決，記錄結論供追溯：

1. **`responsible_party` table 確切結構** — ✅ 已確認：沿用 `BaseRepoImpl.list_by(QueryEntity)` pattern，join `PartyEntity` via `party_uuid`，不需新建 domain service
2. **GRC error code 序號分配** — ✅ 已分配：`GRC_400022` / `GRC_400023` / `GRC_409021`
3. **既有 module-frame import 入口的 parties section 顯示策略** — ✅ 實作：`_load_current_parties` 依 source_type 分支處理（module_frame vs project_ssp）；FE 不條件 render，BE 已補足 current parties
4. **jsdiff vs diff-match-patch** — ✅ v1 用 `diff` package（jsdiff 5.x），效能足夠
5. **Decisions 持久化策略** — ✅ v1 in-memory（Pinia），不做 persistence

---

**狀態**：✅ 已實作完成（2026-05-07）。詳見 §11 偏差記錄 + `docs/conversation-history/2026-05-06-ssp-update-diff/`。

---

## 11. Implementation Reality / Reconciliation（2026-05-07）

本節記錄實作過程中與原始設計（§1–§10）的所有重大偏差及其原因。各節已於內文加 `(updated 2026-05-07: see §11.x)` 標記，此處提供完整 rationale。

與 changelog 的差異：changelog 說「做了什麼」，本節說「為什麼跟設計不同」。

---

### 11.1 Smart default for "added" 從 `use_docx` 改為 `null`

**原始設計**：added items 預設 `use_docx`（自動匯入）。

**實際行為**：added items 的 `default_action = null`；user 必須明確選擇才算決策。

**原因**：預設 `use_docx` 讓 added items 在 user 沒互動的情況下就被計入「已決策」，導致「待決策」filter 看起來空了但其實 user 沒真正審視。User feedback：改成 null 強制 user 對每個 added item 都主動選一次，防呆語意更清楚。

---

### 11.2 BE annotate 保留所有項目（不 filter）

**原始設計**：BE filter 掉 `unchanged` / `gone`，只回傳有差異的項目。

**實際行為**：BE annotate 方法保留所有項目（所有 diff_status）；FE Step 2 自行 filter `unchanged` 不顯示；Step 3 使用完整清單。

**原因**：Step 3（預覽/確認頁）需要完整控制清單才能正確渲染所有 controls + objectives。若 BE 預先 filter，Step 3 只看得到有 diff 的 controls，unchanged controls 就消失了。架構決策：data layer 保持完整，presentation layer 各自 filter。

---

### 11.3 `gone` 項目改為 user 可決策（非 silent preserve）

**原始設計**：`gone`（DB 有但 docx 沒帶）filter 掉，原樣保留不顯示給 user。

**實際行為**：`gone` 項目出現在 Step 2，預設 `keep_current`，user 可切換為 `use_docx`。

- **Controls / objectives**：`use_docx` = 寫入空字串（`implementation_description = ""`，記錄保留、內容清空）
- **Parties**：`use_docx` = **移除（unlink）** 該 party 在當前 entity 的 link record（`oscal_responsible_parties` 該 row 刪除）。底層 `oscal_parties` row **不刪**，因為可能被其他 SSP / module_frame 引用。FE pill label 對 parties 用「移除」（不用「採用新值」/「清空」）以反映語意差異。

**為何兩者語意不同**：controls / objectives 是 entity 內的內容欄位，「清空」就是把欄位寫空；parties 是 entity 之間的關聯（透過 junction table），「不再關聯」自然對應 link 刪除。底層實體（party row）的生命週期由其他 entity 共同決定，不是匯入流程能單方面砍的。

**Hard delete 限制**：`oscal_responsible_parties` schema 沒 `is_active` / 無 audit columns，移除即不可復原（v1 接受此限制；如需 undo 需 schema migration）。

**Multi-role 行為**：一個 party 在同 context 可能有多 role（如同一人是 system-owner + reviewer）；user 選「移除」會刪該 party 在該 context 的**所有** role link，符合 user 心智模型「移除這個人」而非「移除這個 role binding」。

**原因**：User feedback：「DB 有 docx 沒帶」這種情況 user 應該有機會決定「保留」還是「清空」，特別是 user 可能希望用新 docx 版本來去掉舊的目標描述。原本 silent preserve 對 user 不透明。Parties 的 unlink path 在 v1.0 shipped 時為 silent drop，2026-05-07 review 後補完整實作（見 changelog `tweak-ssp-update-diff-gone-parties-unlink.md`）。

---

### 11.4 Filter 從 5 選項簡化為 3（全部 / 待決策 / 已決策）

**原始設計**：5 個篩選器（全部 / 已修改 / 新增 / 將清空 / 待決策）。

**實際行為**：3 個（全部 / 待決策 / 已決策），兩者互斥語意：
- 「待決策」= 該 control 下有任何 decision item 仍在 `default_action`（未 user-touch）
- 「已決策」= 該 control 下**所有** decision items（impl + 每個 AO）都已被 user touch（action !== default）

**原因**：User feedback：diff_status 維度的篩選太技術性，user 更關心「還有什麼要我決定的 / 我都決定了什麼」的任務進度語意。互斥設計讓 filter 行為更直覺。bulkApply cascade 到 AOs 確保 bulk 操作後整條 control 能算作「已決策」。

---

### 11.5 `target_party_uid` 加到 ParsedParty

**原始設計**：`_upsert_party()` 用 `(party_type, name)` 查找。

**實際行為**：diff service 在 match_parties 時，把比對到的 current party 的 uid 存入 `ParsedParty.target_party_uid`；strategy `_upsert_party()` 優先用 uid 查找。

**原因**：當 docx 改了 party 的 name，用 old name 查找會失敗 → `_upsert_party()` 找不到現有 party → 判斷為新 party → APPEND 一筆而非 UPDATE。加 `target_party_uid` 讓已比對的 party 能精準 update。未比對的（genuinely new）party `target_party_uid = None`，走原本 append 路徑。

---

### 11.6 Party enrichment with nickname / login_name

**原始設計**：parties response 含 `matched_user_id`（整數 ID）。

**實際行為**：BE `_enrich_party_user_org_names()` 批次查詢後，填入 `matched_user_nickname` / `matched_user_login_name` / `matched_org_unit_name`；current + parsed parties 都做 enrichment。FE 顯示 `✓ 已連結 ${nickname} (${login_name})` 而非 `(user 17)`。

**原因**：暴露 internal user_id 是資安問題（雖然前後端同 domain，仍不應 leak）；且 FE 無法將 user_id 還原成顯示名，需 BE 提供可讀名稱。

---

### 11.7 Email field aliasing（`email` vs `email_address`）

**原始設計**：未提及欄位名不對稱問題。

**實際行為**：DB current parties dict 用 `email`；docx parser 產出的 parsed parties 用 `email_address`。`ssp_docx_diff_service.py` 的 `_get_party_field()` 統一 normalize 兩個 key name。

**原因**：DB schema 歷史遺留 `email` 欄位名；jedi-oscal parser 產出的 intermediate 用 `email_address`（OSCAL 欄位名）。兩邊都不改，在 diff service 中間層 normalize。

---

### 11.8 Party_type-aware diff fields

**原始設計**：統一比對 5 個欄位（`name` / `email` / `role` / `matched_user_id` / `matched_org_unit_id`）。

**實際行為**：`person` 不比 `matched_org_unit_id`；`organization` 不比 `matched_user_id`。

**原因**：person 的 `matched_org_unit_id` 永遠 null（不 applicable）；organization 的 `matched_user_id` 也永遠 null。若統一比，null vs null 不 diff，沒問題；但 null vs 有值 / 有值 vs null 的情況中，錯誤的欄位型態比較可能誤判為 `changed`。party_type-aware 比較確保不會用錯型態欄位誤判 diff。

---

### 11.9 DB-only objectives 合成為 `gone`

**原始設計**：未處理「DB 有 AO 但 docx 沒帶」的情境。

**實際行為**：BE `_inject_current_objective_descriptions()` 在 annotate 前做一遍：遍歷 DB AOs，若某 `objective_key` 在 parsed result 不存在，合成一筆 `parsed_description=""` → 此 AO 出現為 `diff_status="gone"`，讓 user 可以決定保留或清空。

**原因**：follow-on from §11.3（gone 也要顯示）。若不合成，docx 沒帶的 AO 完全消失在 diff 頁，user 不知道「有幾個 AO 沒被新 docx 覆蓋到」。

---

### 11.10 PrimeVue 3.53 版本特定細節

**原始設計**：未預見 PrimeVue 版本差異問題。

**實際踩到的三個雷：**

1. **`Steps` prop 名**：PrimeVue 3.53 用 `activeStep`（接受 index 值）；舊文件 / 舊版用 `activeIndex`。傳錯 prop 不會有 error，只是 stepper 不動。
2. **`SelectButton` 預設 unselectable**：點已選的選項會 emit `null`，破壞 decisions store（null 覆蓋掉之前的 action）。解法：加 `unselectable="false"`。
3. **`Dropdown` value=null 顯示 placeholder**：PrimeVue 3.x `Dropdown` 當 `modelValue=null` 時，不匹配任何 option，顯示 placeholder 而非選中的 option label。解法：用 sentinel 字串（如 `"__inherit__"`）替代 null，存檔前 transform 回 null。

---

### 11.11 Module_frame source type support

**原始設計**：`_load_current_parties` 只處理 `project_ssp` source。

**實際行為**：`_load_current_parties` 依 `source_type` 分支：若 `module_frame`，查詢 `responsible_party` with `context_type='module_frame'` + 對應 `context_id`。

**原因**：Module_frame import 是比 project_ssp import 更常用的 user flow（用於匯入合規框架模板）。設計階段低估了這個 source 的使用頻率，smoke test 期間 user 即從 module_frame 流程測試，發現 parties 顯示為空。

---

### 11.12 ResponsiblePartyEntity 是 link record，不是完整 party

**原始設計（implementation-plan）**：隱含假設 `_load_current_parties` 可以從 `ResponsiblePartyEntity` 直接讀 `name` / `email_address` 等欄位。

**實際行為**：`ResponsiblePartyEntity` 是 junction table row，只有 `(party_uuid, role_id, context_type, context_id)` 幾個欄位。完整 party info（name / email / type）在 `PartyEntity`，需透過 `party_uuid` join。實作 mirror `ModuleFramePartyService` 的現有 pattern：先拿 link records，再 batch 查 `PartyEntity`。

`_upsert_responsible_party()` 對已存在的 link record（相同 party_uid + role + context 組合）採 idempotent return：`ResponsiblePartyEntity` 無 `uid` 欄位，ORM update path 不成立；`(party_uid, role_id, context_type, context_id)` tuple 本身就是自然 identity，重複 upsert 不需更新任何欄位。

---

### 11.13 Gone parties 的 `use_docx` 是 unlink 語意（review I4 + I10，2026-05-07）

**原始設計**：parties gone + `use_docx` 在 `_filter_parties_for_write` silent log + drop（沒實際 delete path），FE Step 2 也直接隱藏 gone parties。導致 user 看不到「DB 有但 docx 沒帶」的 party，無法決定要不要清掉。

**實際行為**：
- FE Step 2 `PartiesDiffSection` 顯示 gone parties，提供「移除 / 保留」兩個 SelectButton（隱藏「採用新值」— gone 沒新值）
- 「移除」label 比「清空」精準 — controls/AOs 是 wipe content（記錄保留）；parties 是 unlink record（刪除 responsible_party row 但保留 oscal_parties row 給其他 SSP/MF 共用）
- BE `confirm_import` 收到 `parties_decisions[].action='use_docx'` 且 party 屬 gone → 走 `strategy.delete_parties(party_uids)` 路徑刪 link record；其他 SSP/module_frame 引用同 oscal_party 不受影響
- Step 3 viewModel `_flattenDiffParties` 對 gone+use_docx 的 party 直接過濾（等於要刪掉）；keep_current 的 gone party 顯示為 readonly

### 11.14 Adapter failure surfacing（review I9，2026-05-07）

**原始設計**：v2 adapter 解析失敗（`app_service.py` 內 try/except）只 logger.warning + 把 parsed_dict 部分欄位（parties / leveraged_services / info_types）留空。User 看 Step 3「沒有 parties」直接按確認 → docx 內容永久遺失。

**實際行為**：
- BE response 加 `adapter_status`（per-section dict） + `adapter_error`（per-section user-friendly 訊息對照表）
- FE 在 Step 2 / Step 3 都顯示 banner：
  - Soft warning：non-critical section 失敗（leveraged_services / info_types）→ 橘色 banner 提醒，allow continue
  - Hard block：critical section 失敗（parties）→ 紅色 banner + **disable confirm 按鈕**，user 必須重新上傳
- Banner 接在 SspDocxImportPage 內聯渲染（review C1：原本 ImportOutline 是 orphan 沒人 render）

### 11.15 Stepper 視覺固定 by mode（2026-05-07）

**原始設計**：3 步 stepper 永遠存在，BE 用 `mode === 'update'` 判斷是否需 Step 2（已知為錯誤的 mode 比對 — §11.4）。

**實際行為**：
- Stepper 視覺由 `isCreate` 決定：create mode 永遠 2 格（隱藏「解決差異」），update mode 永遠 3 格（即使 docx 無 diff 也佔位）
- 內部 `step.value` 維持 1/2/3 編碼避免大改 template，新增 `activeStepIndex` computed mapping
- update mode 永遠進 Step 2（即使 diff_summary 全 0），讓 user 過目「無需決策」的 empty state — UX 一致性

### 11.16 Code review critical / important 修正（2026-05-07）

完整 review 報告：`docs/review/2026-05-07-ssp-update-diff-review.md`（5-agent parallel review）。

**Critical 6 全部修完（C1-C6）**：
- C1：Step 3 diff summary banner 永遠不顯示（mode 比對錯）→ FE inline render 不再依賴 mode
- C2：`diffStore.reset()` filter 改回 `'pending'` 回歸 bug → 改回 `'all'`
- C3：`parties_decisions` payload fallback 用 `'keep_current'` vs store 的 `'skip'` 不一致 → 統一走 `buildConfirmPayload()`
- C4：BE `confirm_import` parties 寫入吞所有 DB 錯回傳 success → 移除外層 catch，DB 錯一律 raise
- C5：`_load_current_parties` source 不存在 silent 回 [] → 改 raise NotFound + GET 路徑加 `_check_permission`
- C6：`_filter_parties_for_write` annotated=None + decisions 非空 → fallback 寫所有 parties → 改成嚴格按 decisions 走

**Important 12 處理大宗（I1-I12）**：narrow bare excepts / module-level logger / docstring tightening / I12 Step 3→Step 1 confirm dialog 等。剩餘 minor 已記錄於 SUMMARY §5。
