# SSP 重新匯入差異審查 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** SSP 重新上傳 docx 時新增 Step 2 Diff 解決頁，逐項顯示「DB 現值 vs docx 新值」並讓 user 決策（保留 / 採用新值 / 跳過），最後 confirm 時只寫入勾選為 `use_docx` 的項目。

**Architecture:** 不加新表、既有 endpoint URL 不變，只擴充 parse / confirm 的 schema。BE 計算 smart default + diff_status，FE 用 jsdiff 算 word-level highlight 並渲染 side-by-side 並排卡。Step 2 為新插入步驟，Step 3 既有元件接收 decisions 進入 readonly mode。

**Tech Stack:** Python 3 + Flask-RESTful + SQLAlchemy + DDD layered (BE) / Vue 3 + PrimeVue + Pinia + jsdiff (FE) / Cucumber + Playwright (E2E)

**Spec：** [`design.md`](./design.md)

> **⚠️ Post-Implementation Note（2026-05-07）：** 此計畫已完成（BE 30 commits + FE 52 commits，branch `feature/ssp-update-diff`）。實作中有若干與計畫的偏差，包含：`added` items 改為 `default_action=null`（非 `use_docx`）、BE annotate 保留所有 items 不 filter、`gone` 改為顯示給 user 決策、filter 從 5 選項改為 3、`ParsedParty.target_party_uid` 新增欄位、party enrichment（nickname/login_name）、email field aliasing、party_type-aware diff、DB-only AO 合成、module_frame source type 支援。完整偏差原因請見 `design.md §11`（Implementation Reality / Reconciliation）。

---

## Pre-Implementation Verification

### V1: 確認 `responsible_party` / `parties` infrastructure 已存在

```bash
# 確認 jedi-oscal 有 party domain service
ls ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/domain/repository/base/responsible_party.py

# 確認主專案 strategy.write_parties() 已存在
grep -n "def write_parties" /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/domain/oscal/strategy/ssp_write_strategy.py
# 預期：line 282
```

預期結果：兩個都存在 → Phase 1 不需新增 domain service / strategy method。

### V2: 確認 GRC error code 最新序號

```bash
grep -E "GRC_(404|409|400)\d+" /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/common/code/grc_error_code.py | tail -10
```

當前最新（plan-stage 確認，截至 2026-05-06，已含 party 相關既有 codes）：
- 404: `GRC_DOCX_PARSE_JOB_NOT_FOUND` = `GRC_404023`（將沿用）
- 400: `GRC_PARTY_NAME_REQUIRED = GRC_400015`、`GRC_PARTY_ROLE_REQUIRED = GRC_400016` 已被佔用（line 155-157）
- 409: 最新可見為 `GRC_DRIVE_INTEGRATION_ALREADY_EXISTS` = `GRC_409020`

新 error codes 將分配於 **`GRC_400017 / 400018` / `GRC_409021`**（避開已佔序號）。實作前再 grep 一次確認無新 commit 佔用。

---

## File Structure

### BE 新增檔案
- `app/oscal/service/ssp_docx_diff_service.py` — diff 計算邏輯（smart default / matching / summary）
- `tests/test_ssp_docx_diff_service.py` — diff service unit tests
- `tests/test_ssp_docx_import_diff_flow.py` — confirm endpoint integration test（update mode w/ decisions）
- `tests/test_ssp_docx_import_create_no_diff.py` — create mode regression test

### BE 修改檔案
- `app/oscal/service/ssp_docx_import_app_service.py` — wire diff service 進 parse + confirm
- `api/oscal/serializers/ssp/ssp_docx_import.py` — 擴充 response schema（has_diff / diff_summary / diff_status / default_action / parties.current_values / parties.parsed_values）
- `api/oscal/routes/ssp/ssp_docx_import_route.py` — 擴充 confirm payload schema（parties_decisions）
- `common/code/grc_error_code.py` — 新增 3 個 error codes
- `di_containers/oscal/oscal_containers.py` — 若 diff service 需 wiring（domain service 注入）

### FE 新增檔案（`compliance-manager-fe/`）
- `src/stores/ssp-docx-import.js` — Pinia store
- `src/utils/textDiff.js` — jsdiff 包裝層
- `src/components/grc/ssp-docx-import-v2/diff/DiffResolutionStep.vue`
- `src/components/grc/ssp-docx-import-v2/diff/DiffSectionShell.vue`
- `src/components/grc/ssp-docx-import-v2/diff/ControlDiffSection.vue`
- `src/components/grc/ssp-docx-import-v2/diff/ControlDiffCard.vue`
- `src/components/grc/ssp-docx-import-v2/diff/ObjectiveDiffRow.vue`
- `src/components/grc/ssp-docx-import-v2/diff/PartiesDiffSection.vue`
- `src/components/grc/ssp-docx-import-v2/diff/PartyDiffCard.vue`

### FE 修改檔案
- `src/components/grc/ssp-docx-import-v2/SspDocxImportPage.vue` — 加入 Step 2 stepper progression
- `src/components/grc/ssp-docx-import-v2/ControlImplSection.vue` — 接收 decisions、filter 跟 readonly
- `src/components/grc/ssp-docx-import-v2/PartiesSection.vue` — 同上
- `src/components/grc/ssp-docx-import-v2/ImportOutline.vue` — 顯示 diff_summary
- `package.json` — 加 jsdiff 依賴（`diff` package）

### E2E 新增檔案（`compliance-manager-test/`）
- `requirements/ssp-update-diff.md`
- `specs/ssp-update-diff.feature`
- `features/steps/ssp-update-diff.steps.js`
- `features/pages/SspDocxImportDiffPage.js`

---

# Phase 1 — BE Diff Service（read-only changes）

> 階段目標：完成 diff 計算邏輯 + parse response 擴充。完成後既有 create flow 不變，update mode 開始回傳 diff metadata。Confirm endpoint 暫不改。

## Task 1.1: 建立 SspDocxDiffService（純邏輯類）

**Files:**
- Create: `app/oscal/service/ssp_docx_diff_service.py`
- Test: `tests/test_ssp_docx_diff_service.py`

`SspDocxDiffService` 是 stateless 純邏輯類，不查 DB（DB 查詢由 caller `SspDocxImportAppService` 負責），輸入 current + parsed，輸出 diff metadata。

- [ ] **Step 1: Write failing test for `compute_default_action_for_text`**

```python
# tests/test_ssp_docx_diff_service.py
import pytest
from app.oscal.service.ssp_docx_diff_service import SspDocxDiffService

class TestComputeDefaultActionForText:
    def setup_method(self):
        self.svc = SspDocxDiffService()

    def test_current_empty_parsed_has_value_returns_use_docx(self):
        result = self.svc.compute_default_action_for_text(current="", parsed="ABAC 機制")
        assert result == ("added", "use_docx")

    def test_current_none_parsed_has_value_returns_use_docx(self):
        result = self.svc.compute_default_action_for_text(current=None, parsed="ABAC 機制")
        assert result == ("added", "use_docx")

    def test_identical_after_strip_returns_unchanged(self):
        result = self.svc.compute_default_action_for_text(current="  RBAC  ", parsed="RBAC")
        assert result == ("unchanged", None)

    def test_different_returns_keep_current(self):
        result = self.svc.compute_default_action_for_text(current="RBAC 機制", parsed="ABAC 機制")
        assert result == ("changed", "keep_current")

    def test_current_has_value_parsed_empty_returns_gone(self):
        result = self.svc.compute_default_action_for_text(current="RBAC 機制", parsed="")
        assert result == ("gone", None)
```

- [ ] **Step 2: Run test to verify it fails**

```bash
cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be
pytest tests/test_ssp_docx_diff_service.py::TestComputeDefaultActionForText -v
```

Expected: 5 fails with `ModuleNotFoundError` or `AttributeError`

- [ ] **Step 3: Implement `compute_default_action_for_text`**

```python
# app/oscal/service/ssp_docx_diff_service.py
from typing import Optional, Tuple, Literal

DiffStatus = Literal["unchanged", "changed", "added", "gone"]
DefaultAction = Literal["keep_current", "use_docx"]


class SspDocxDiffService:
    """Stateless diff calculator. No DB queries — caller passes current + parsed."""

    def compute_default_action_for_text(
        self,
        current: Optional[str],
        parsed: Optional[str],
    ) -> Tuple[DiffStatus, Optional[DefaultAction]]:
        """Per design §3 smart default table.

        Returns (diff_status, default_action). default_action is None for
        unchanged / gone (those are filtered out of the response).
        """
        cur_norm = (current or "").strip()
        par_norm = (parsed or "").strip()

        if not cur_norm and par_norm:
            return ("added", "use_docx")
        if cur_norm and not par_norm:
            return ("gone", None)
        if cur_norm == par_norm:
            return ("unchanged", None)
        return ("changed", "keep_current")
```

- [ ] **Step 4: Run test to verify pass**

```bash
pytest tests/test_ssp_docx_diff_service.py::TestComputeDefaultActionForText -v
```

Expected: 5 PASS

- [ ] **Step 5: Commit**

```bash
git add app/oscal/service/ssp_docx_diff_service.py tests/test_ssp_docx_diff_service.py
git commit -m "feat(ssp-update-diff): add SspDocxDiffService.compute_default_action_for_text"
```

---

## Task 1.2: 加入 `match_parties` 模糊比對

**Files:**
- Modify: `app/oscal/service/ssp_docx_diff_service.py`
- Modify: `tests/test_ssp_docx_diff_service.py`

依 design §6.1 edge case rules：用 `(name+email)` 模糊比對配對 current ↔ parsed parties。

- [ ] **Step 1: Write failing test**

```python
# Append to tests/test_ssp_docx_diff_service.py
class TestMatchParties:
    def setup_method(self):
        self.svc = SspDocxDiffService()

    def test_match_by_name_and_email(self):
        current = [{"party_uid": "u1", "name": "王小明", "email": "ming@old.com"}]
        parsed = [{"name": "王小明", "email": "ming@new.com"}]  # email 改了，name 同
        pairs, only_current, only_parsed = self.svc.match_parties(current, parsed)
        assert len(pairs) == 1
        assert pairs[0][0]["party_uid"] == "u1"
        assert pairs[0][1]["email"] == "ming@new.com"
        assert only_current == []
        assert only_parsed == []

    def test_no_match_separates_into_only_lists(self):
        current = [{"party_uid": "u1", "name": "張三", "email": "san@example.com"}]
        parsed = [{"name": "李四", "email": "si@example.com"}]
        pairs, only_current, only_parsed = self.svc.match_parties(current, parsed)
        assert pairs == []
        assert len(only_current) == 1
        assert len(only_parsed) == 1

    def test_match_when_email_missing_falls_back_to_name(self):
        current = [{"party_uid": "u1", "name": "王小明", "email": ""}]
        parsed = [{"name": "王小明", "email": ""}]
        pairs, _, _ = self.svc.match_parties(current, parsed)
        assert len(pairs) == 1

    def test_match_supports_email_address_alternate_spelling(self):
        """current 從 ResponsibleParty 拿可能用 email_address；parsed 用 email — 兩邊都要 work"""
        current = [{"party_uid": "u1", "name": "王小明", "email_address": "ming@x.com"}]
        parsed = [{"name": "王小明", "email": "ming@x.com"}]
        pairs, _, _ = self.svc.match_parties(current, parsed)
        assert len(pairs) == 1
```

- [ ] **Step 2: Run test, verify FAIL**

```bash
pytest tests/test_ssp_docx_diff_service.py::TestMatchParties -v
```

Expected: 3 FAIL

- [ ] **Step 3: Implement**

```python
# Append to app/oscal/service/ssp_docx_diff_service.py
def _party_key(self, party: dict) -> str:
    name = (party.get("name") or "").strip().lower()
    email = (party.get("email") or party.get("email_address") or "").strip().lower()
    return f"{name}|{email}"

def match_parties(self, current: list[dict], parsed: list[dict]):
    """Match current vs parsed parties by (name+email).

    Returns (pairs, only_current, only_parsed):
      - pairs: list of (current_party, parsed_party) tuples
      - only_current: current parties with no parsed match (will be diff_status="gone")
      - only_parsed: parsed parties with no current match (will be diff_status="added")
    """
    parsed_by_key = {self._party_key(p): p for p in parsed}
    pairs = []
    only_current = []

    for cur in current:
        key = self._party_key(cur)
        if key in parsed_by_key:
            pairs.append((cur, parsed_by_key.pop(key)))
        else:
            only_current.append(cur)

    only_parsed = list(parsed_by_key.values())
    return pairs, only_current, only_parsed
```

- [ ] **Step 4: Run test, verify PASS**

```bash
pytest tests/test_ssp_docx_diff_service.py::TestMatchParties -v
```

Expected: 3 PASS

- [ ] **Step 5: Commit**

```bash
git add app/oscal/service/ssp_docx_diff_service.py tests/test_ssp_docx_diff_service.py
git commit -m "feat(ssp-update-diff): add match_parties fuzzy matching"
```

---

## Task 1.3: 加入 `compute_party_diff` (per-party 整體 diff)

**Files:**
- Modify: `app/oscal/service/ssp_docx_diff_service.py`
- Modify: `tests/test_ssp_docx_diff_service.py`

每個 matched pair 比對 4 個欄位（name / email / role / matched_user_id），任一不同就是 changed。

- [ ] **Step 1: Write failing test**

```python
# Append to tests/test_ssp_docx_diff_service.py
class TestComputePartyDiff:
    def setup_method(self):
        self.svc = SspDocxDiffService()

    def test_identical_parties_returns_unchanged(self):
        cur = {"name": "王小明", "email": "m@x.com", "role": "admin", "matched_user_id": 42}
        par = {"name": "王小明", "email": "m@x.com", "role": "admin", "matched_user_id": 42}
        diff_status, default_action = self.svc.compute_party_diff(cur, par)
        assert diff_status == "unchanged"
        assert default_action is None

    def test_email_change_returns_changed_keep_current(self):
        cur = {"name": "王小明", "email": "old@x.com", "role": "admin", "matched_user_id": 42}
        par = {"name": "王小明", "email": "new@x.com", "role": "admin", "matched_user_id": 42}
        result = self.svc.compute_party_diff(cur, par)
        assert result == ("changed", "keep_current")

    def test_added_party_returns_use_docx(self):
        result = self.svc.compute_party_diff(None, {"name": "新人", "email": "new@x.com"})
        assert result == ("added", "use_docx")
```

- [ ] **Step 2: Run, verify FAIL**

```bash
pytest tests/test_ssp_docx_diff_service.py::TestComputePartyDiff -v
```

- [ ] **Step 3: Implement**

```python
# Append
def compute_party_diff(self, current, parsed):
    if current is None and parsed is not None:
        return ("added", "use_docx")
    if current is not None and parsed is None:
        return ("gone", None)
    fields = ("name", "email", "role", "matched_user_id", "matched_org_unit_id")
    for f in fields:
        if (current.get(f) or "") != (parsed.get(f) or ""):
            return ("changed", "keep_current")
    return ("unchanged", None)
```

- [ ] **Step 4: Run, verify PASS**

- [ ] **Step 5: Commit**

```bash
git commit -am "feat(ssp-update-diff): add compute_party_diff"
```

---

## Task 1.4: 加入 `build_diff_summary` 統計

**Files:**
- Modify: `app/oscal/service/ssp_docx_diff_service.py`
- Modify: `tests/test_ssp_docx_diff_service.py`

依 design §4.1 response 結構，回傳 controls / objectives / parties 三 sections 的 changed / added / total_with_diff 統計。

- [ ] **Step 1: Write test**

```python
class TestBuildDiffSummary:
    def setup_method(self):
        self.svc = SspDocxDiffService()

    def test_counts_changed_and_added_per_section(self):
        annotated = {
            "matched_controls": [
                {"diff_status": "changed", "objectives": [{"diff_status": "added"}]},
                {"diff_status": "added", "objectives": []},
            ],
            "parties": [
                {"diff_status": "changed"},
                {"diff_status": "added"},
                {"diff_status": "added"},
            ],
        }
        s = self.svc.build_diff_summary(annotated)
        assert s["controls"] == {"changed": 1, "added": 1, "total_with_diff": 2}
        assert s["objectives"] == {"changed": 0, "added": 1, "total_with_diff": 1}
        assert s["parties"] == {"changed": 1, "added": 2, "total_with_diff": 3}

    def test_empty_annotated_returns_zeros(self):
        s = self.svc.build_diff_summary({"matched_controls": [], "parties": []})
        assert s["controls"]["total_with_diff"] == 0
        assert s["parties"]["total_with_diff"] == 0
```

- [ ] **Step 2: Run, FAIL**

- [ ] **Step 3: Implement**

```python
def build_diff_summary(self, annotated: dict) -> dict:
    def _count(items):
        c = sum(1 for i in items if i.get("diff_status") == "changed")
        a = sum(1 for i in items if i.get("diff_status") == "added")
        return {"changed": c, "added": a, "total_with_diff": c + a}

    controls = annotated.get("matched_controls", [])
    objectives = [obj for ctrl in controls for obj in (ctrl.get("objectives") or [])]
    parties = annotated.get("parties", [])

    return {
        "controls": _count(controls),
        "objectives": _count(objectives),
        "parties": _count(parties),
    }
```

- [ ] **Step 4: Run, PASS**

- [ ] **Step 5: Commit**

```bash
git commit -am "feat(ssp-update-diff): add build_diff_summary"
```

---

## Task 1.5: `annotate_parse_result` — 整合 entrypoint

**Files:**
- Modify: `app/oscal/service/ssp_docx_diff_service.py`
- Modify: `tests/test_ssp_docx_diff_service.py`

Entrypoint method：吃完整 parse_result + current_state，輸出 annotated parse_result（含 diff_status / default_action，且 filter 掉 unchanged / gone 的項目）。

- [ ] **Step 1: Write integration-style test**

```python
class TestAnnotateParseResult:
    def setup_method(self):
        self.svc = SspDocxDiffService()

    def test_filters_unchanged_controls(self):
        parse_result = {
            "matched_controls": [
                {
                    "control_id": "AC-1",
                    "current_implementation_description": "same text",
                    "parsed_implementation_description": "same text",
                    "objectives": [],
                },
                {
                    "control_id": "AC-2",
                    "current_implementation_description": "RBAC",
                    "parsed_implementation_description": "ABAC",
                    "objectives": [],
                },
            ],
            "parties": [],
        }
        current_parties = []
        result = self.svc.annotate_parse_result(parse_result, current_parties)
        assert len(result["matched_controls"]) == 1
        assert result["matched_controls"][0]["control_id"] == "AC-2"
        assert result["matched_controls"][0]["diff_status"] == "changed"
        assert result["matched_controls"][0]["default_action"] == "keep_current"
        assert result["has_diff"] is True

    def test_no_diff_sets_has_diff_false(self):
        parse_result = {
            "matched_controls": [
                {
                    "control_id": "AC-1",
                    "current_implementation_description": "same",
                    "parsed_implementation_description": "same",
                    "objectives": [],
                }
            ],
            "parties": [],
        }
        result = self.svc.annotate_parse_result(parse_result, [])
        assert result["has_diff"] is False
        assert result["matched_controls"] == []

    def test_added_party_appears_with_synthesized_uid(self):
        parse_result = {
            "matched_controls": [],
            "parties": [{"name": "新人", "email": "new@x.com", "role": "admin"}],
        }
        result = self.svc.annotate_parse_result(parse_result, current_parties=[])
        assert len(result["parties"]) == 1
        p = result["parties"][0]
        assert p["diff_status"] == "added"
        assert p["default_action"] == "use_docx"
        assert p["party_uid"]  # synthesized
        assert p["current_values"] is None
```

- [ ] **Step 2: Run, FAIL**

- [ ] **Step 3: Implement**

```python
import uuid

def annotate_parse_result(self, parse_result: dict, current_parties: list[dict]) -> dict:
    """Annotate parse_result with diff_status / default_action per item, filter
    out unchanged / gone items, and add has_diff + diff_summary.

    Returns a NEW dict (does not mutate input).
    """
    annotated = dict(parse_result)

    # Controls + objectives
    annotated_controls = []
    for ctrl in parse_result.get("matched_controls", []):
        ctrl_status, ctrl_action = self.compute_default_action_for_text(
            ctrl.get("current_implementation_description"),
            ctrl.get("parsed_implementation_description"),
        )
        annotated_objectives = []
        for obj in (ctrl.get("objectives") or []):
            obj_status, obj_action = self.compute_default_action_for_text(
                obj.get("current_description"),
                obj.get("parsed_description"),
            )
            if obj_status in ("unchanged", "gone"):
                continue
            annotated_obj = {**obj, "diff_status": obj_status, "default_action": obj_action}
            annotated_objectives.append(annotated_obj)

        # Skip control entirely if both impl AND all objectives are unchanged/gone
        if ctrl_status in ("unchanged", "gone") and not annotated_objectives:
            continue

        annotated_ctrl = {
            **ctrl,
            "diff_status": ctrl_status if ctrl_status != "unchanged" else "unchanged",
            "default_action": ctrl_action,
            "objectives": annotated_objectives,
        }
        annotated_controls.append(annotated_ctrl)

    annotated["matched_controls"] = annotated_controls

    # Parties
    pairs, _gone, only_parsed = self.match_parties(
        current_parties, parse_result.get("parties", [])
    )
    annotated_parties = []
    for cur, par in pairs:
        status, action = self.compute_party_diff(cur, par)
        if status == "unchanged":
            continue
        annotated_parties.append({
            "party_uid": cur.get("party_uid"),
            "party_type": par.get("party_type") or cur.get("party_type"),
            "current_values": cur,
            "parsed_values": par,
            "diff_status": status,
            "default_action": action,
        })
    for par in only_parsed:
        annotated_parties.append({
            "party_uid": f"new-{uuid.uuid4()}",
            "party_type": par.get("party_type"),
            "current_values": None,
            "parsed_values": par,
            "diff_status": "added",
            "default_action": "use_docx",
        })
    annotated["parties"] = annotated_parties

    # Summary + has_diff
    annotated["diff_summary"] = self.build_diff_summary(annotated)
    annotated["has_diff"] = (
        annotated["diff_summary"]["controls"]["total_with_diff"] > 0
        or annotated["diff_summary"]["objectives"]["total_with_diff"] > 0
        or annotated["diff_summary"]["parties"]["total_with_diff"] > 0
    )

    return annotated
```

- [ ] **Step 4: Run, PASS**

- [ ] **Step 5: Commit**

```bash
git commit -am "feat(ssp-update-diff): add annotate_parse_result entrypoint"
```

---

## Task 1.6: 在 SspDocxImportAppService 串入 diff service

**Files:**
- Modify: `app/oscal/service/ssp_docx_import_app_service.py`
- Modify: `di_containers/oscal/oscal_containers.py`
- Modify: `api/oscal/serializers/ssp/ssp_docx_import.py`

注入 diff service，呼叫 `annotate_parse_result()`，並在 serializer 加新欄位。

- [ ] **Step 1: Add diff service to DI container**

```python
# di_containers/oscal/oscal_containers.py
# 在既有 ssp_docx_import_app_service provider 旁邊
from app.oscal.service.ssp_docx_diff_service import SspDocxDiffService

ssp_docx_diff_service = providers.Singleton(SspDocxDiffService)

# 修改既有 provider:
ssp_docx_import_app_service = providers.Singleton(
    SspDocxImportAppService,
    # ... existing args
    diff_service=ssp_docx_diff_service,  # 新增
)
```

- [ ] **Step 2: Add `_load_current_parties()` helper to app service**

```python
# app/oscal/service/ssp_docx_import_app_service.py
# 在 SspDocxImportAppService 內加新方法
def _load_current_parties(self, source_uid: str, source_type: str) -> list[dict]:
    """Load existing parties for SSP. Returns empty list for module_frame
    or when SSP has no parties yet."""
    if source_type != "project_ssp":
        return []
    # 從 ssp_domain_service 拿 SSP entity，再透過 responsible_party domain service 查
    # 細節依既有 jedi-oscal 提供的 query API。例：
    ssp = self._ssp_domain_service.get_by_uid(source_uid)
    if ssp is None:
        return []
    # 用 IBaseRepo 標準 list_by(QueryEntity) pattern，避免猜測自訂 method 名
    from jedi_oscal.domain.entity.base.oscal_responsible_party_query_entity import (
        ResponsiblePartyQueryEntity,
    )
    parties = self._responsible_party_domain_service.list_by(
        ResponsiblePartyQueryEntity(context_type="ssp", context_id=ssp.id)
    )
    # Translate to plain dict matching parsed_parties shape
    return [
        {
            "party_uid": p.party_uid,
            "party_type": p.party_type,
            "name": p.name,
            "email": p.email_address,
            "role": p.role_id,
            "matched_user_id": p.user_id,
            "matched_org_unit_id": p.org_unit_id,
        }
        for p in parties
    ]
```

> **注意**：上面用 `list_by(QueryEntity)` 是 jedi-oscal `IBaseRepo` 標準 query pattern。實作前 grep 確認 `IResponsibleParty` 有繼承 IBaseRepo 並提供 `list_by` method（`grep -n "def list_by\|class.*IResponsibleParty" jedi-oscal/jedi_oscal/domain/repository/base/responsible_party.py`）。若 query entity 欄位名稱不是 `context_type` / `context_id`，依實際 entity 定義調整。

- [ ] **Step 3: Modify `get_parse_result()` to call diff service**

找到既有 `get_parse_result()` 方法（grep `def get_parse_result`），在 return 前插入：

```python
@transaction
def get_parse_result(self, parse_uid: str, user_id: str):
    job = self._parse_job_repo.get_by_uid(parse_uid)
    if job is None:
        raise NotFound(GrcErrorCode.GRC_DOCX_PARSE_JOB_NOT_FOUND)

    # 既有：parse_result 從 job.parsed_result 取出
    parse_result = self._build_parse_result_view(job)

    # 新增：annotate with diff metadata
    current_parties = self._load_current_parties(job.source_uid, job.source_type)
    annotated = self._diff_service.annotate_parse_result(parse_result, current_parties)

    return annotated
```

- [ ] **Step 4: Update serializer to expose new fields**

```python
# api/oscal/serializers/ssp/ssp_docx_import.py
class SspDocxImportPreviewSchema(Schema):
    # 既有欄位...
    has_diff = fields.Bool()  # 新增
    diff_summary = fields.Dict()  # 新增（含 controls/objectives/parties counts）
    matched_controls = fields.List(fields.Nested("MatchedControlSchema"))
    parties = fields.List(fields.Nested("PartyDiffSchema"))  # 新增 schema

class MatchedControlSchema(Schema):
    # 既有
    diff_status = fields.Str()  # 新增
    default_action = fields.Str(allow_none=True)  # 新增
    objectives = fields.List(fields.Nested("ObjectiveDiffSchema"))

class ObjectiveDiffSchema(Schema):
    # 既有
    diff_status = fields.Str()
    default_action = fields.Str(allow_none=True)

class PartyDiffSchema(Schema):
    party_uid = fields.Str()
    party_type = fields.Str()
    current_values = fields.Dict(allow_none=True)
    parsed_values = fields.Dict(allow_none=True)
    diff_status = fields.Str()
    default_action = fields.Str(allow_none=True)
```

- [ ] **Step 5: Manual verification**

啟動 BE，呼叫 GET `/ssp-docx-import/<uid>` for an update-mode job，確認 response 含 `has_diff` / `diff_summary` / `matched_controls[].diff_status` 欄位。

```bash
python main_app.py &
# 用既有 parse_uid 呼叫
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/ssp-docx-import/<uid> | jq '.has_diff, .diff_summary'
```

- [ ] **Step 6: Commit**

```bash
git add app/oscal/service/ssp_docx_import_app_service.py \
        di_containers/oscal/oscal_containers.py \
        api/oscal/serializers/ssp/ssp_docx_import.py
git commit -m "feat(ssp-update-diff): wire SspDocxDiffService into parse response"
```

---

## Task 1.7: Phase 1 Changelog

**Files:**
- Create: `docs/changelog/2026-05-XX-feat-ssp-update-diff-be-phase1.md`

依 CLAUDE.md「Changelog 分類規範」：

- [ ] **Step 1: 寫 changelog**

```markdown
---
type: feat
modules: [oscal, ssp]
issue: docs/issues/pending/ssp-update-diff.md
---

# Phase 1 — BE diff service & parse response 擴充

## 需求
SSP 重新匯入時，user 看不到 docx 新值與 DB 現值的差異，可能無預警覆蓋手動編輯內容。Phase 1 在 BE parse response 加上 diff metadata（diff_status / default_action / current_values for parties），FE 後續用此資訊渲染 Step 2 Diff 解決頁。

## 變更範圍
- 新增 `app/oscal/service/ssp_docx_diff_service.py`（純邏輯類）
- 改 `app/oscal/service/ssp_docx_import_app_service.py`（注入 diff service，annotate parse result）
- 改 `api/oscal/serializers/ssp/ssp_docx_import.py`（擴充 response schema）
- 新增 `tests/test_ssp_docx_diff_service.py`

## API 變更（additive only）
GET `/ssp-docx-import/<uid>` response 新增：
- `has_diff: bool`
- `diff_summary: { controls, objectives, parties }`
- `matched_controls[].diff_status / .default_action`
- `matched_controls[].objectives[].diff_status / .default_action`
- `parties[].current_values / .parsed_values / .diff_status / .default_action`

既有欄位行為不變，FE create flow 完全不受影響（has_diff=false）。

## 測試結果
pytest tests/test_ssp_docx_diff_service.py -v: 10+ tests pass
```

- [ ] **Step 2: Commit**

```bash
git add docs/changelog/
git commit -m "docs(changelog): Phase 1 BE diff service"
```

---

# Phase 2 — BE Confirm Endpoint 擴充

> 階段目標：confirm payload 接收 `parties_decisions`，在 app service filter 後再呼叫既有 `write_parties()`。

## Task 2.1: 新增 GRC error codes

**Files:**
- Modify: `common/code/grc_error_code.py`

- [ ] **Step 1: 加入 3 個新 codes**

```python
# 在既有 GrcErrorCode class 適當位置插入
GRC_DOCX_DECISIONS_INVALID_CONTROL_ID = ("Decisions 包含未知 control_id",         "GRC_400017")
GRC_DOCX_DECISIONS_INVALID_PARTY_UID  = ("Parties decisions 包含未知 party_uid", "GRC_400018")
GRC_DOCX_PARSE_ALREADY_CONFIRMED      = ("此 Parse 已 confirm，請重新上傳",       "GRC_409021")
```

- [ ] **Step 2: Commit**

```bash
git commit -am "feat(ssp-update-diff): add 3 GRC error codes for confirm validation"
```

---

## Task 2.2: 擴充 confirm payload schema (parties_decisions)

**Files:**
- Modify: `api/oscal/routes/ssp/ssp_docx_import_route.py`

- [ ] **Step 1: 加 schema field**

找到既有 `SspDocxImportConfirmRoute` 內的 `use_kwargs` schema（grep `parties_decisions\|class.*ConfirmSchema`）：

```python
# 在既有 ConfirmRequestSchema (or wherever payload schema is) 加：
parties_decisions = fields.List(
    fields.Nested(PartiesDecisionItemSchema),
    required=False,
    load_default=list,
)

class PartiesDecisionItemSchema(Schema):
    party_uid = fields.Str(required=True)
    action = fields.Str(
        required=True,
        validate=validate.OneOf(["use_docx", "keep_current", "skip"]),
    )
```

- [ ] **Step 2: Commit**

```bash
git commit -am "feat(ssp-update-diff): extend confirm payload with parties_decisions"
```

---

## Task 2.3: 串入 confirm flow — filter parties by decisions

**Files:**
- Modify: `app/oscal/service/ssp_docx_import_app_service.py`
- Test: `tests/test_ssp_docx_import_diff_flow.py`

- [ ] **Step 1: Write integration test (RED)**

```python
# tests/test_ssp_docx_import_diff_flow.py
def test_confirm_with_mixed_party_decisions_only_writes_use_docx(client, headers, fixture_update_mode_parse):
    """Update mode: 3 parties (1 keep, 1 use_docx, 1 skip) → only use_docx written."""
    parse_uid = fixture_update_mode_parse["parse_uid"]
    payload = {
        "decisions": [],
        "parties_decisions": [
            {"party_uid": fixture_update_mode_parse["party_uids"][0], "action": "keep_current"},
            {"party_uid": fixture_update_mode_parse["party_uids"][1], "action": "use_docx"},
            {"party_uid": fixture_update_mode_parse["party_uids"][2], "action": "skip"},
        ],
        "content_overrides": {},
    }

    resp = client.post(
        f"/ssp-docx-import/{parse_uid}/confirm",
        json=payload,
        headers=headers,
    )
    assert resp.status_code == 200, resp.json
    assert resp.json["status"] is True

    # 查 DB 驗證：只 use_docx 那筆寫入
    # （fixture 提供 ssp_id，可查 responsible_party 表）
    written_parties = query_responsible_parties(fixture_update_mode_parse["ssp_id"])
    expected_email = fixture_update_mode_parse["use_docx_party_new_email"]
    assert any(p.email == expected_email for p in written_parties)
```

> Fixture `fixture_update_mode_parse` 需新建（在 `conftest.py` 或本測試檔內），預先建好一個 SSP + 3 parties 並產一個 parse_job，回傳必要 ID。

- [ ] **Step 2: Run, FAIL**

```bash
pytest tests/test_ssp_docx_import_diff_flow.py -v
```

- [ ] **Step 3: Implement filter logic in `confirm_import()`**

```python
# app/oscal/service/ssp_docx_import_app_service.py
@transaction
def confirm_import(self, parse_uid, decisions, parties_decisions, content_overrides, user_id):
    job = self._parse_job_repo.get_by_uid(parse_uid)
    if job is None:
        raise NotFound(GrcErrorCode.GRC_DOCX_PARSE_JOB_NOT_FOUND)
    if job.status == "confirmed":
        raise ConflictError(GrcErrorCode.GRC_DOCX_PARSE_ALREADY_CONFIRMED)

    parse_result = self._build_parse_result_view(job)

    # Validate decisions
    self._validate_decisions(parse_result, decisions, parties_decisions)

    # 既有：filter controls + write
    filtered_controls = self._filter_controls_by_decisions(parse_result, decisions)
    strategy.write(filtered_controls, content_overrides, user_id)

    # 新增：filter parties + write_parties
    filtered_parties = self._filter_parties_by_decisions(parse_result, parties_decisions)
    if filtered_parties:
        strategy.write_parties(filtered_parties, source_uid=job.source_uid, user_id=user_id)

    # Mark confirmed
    job.status = "confirmed"
    self._parse_job_repo.update(job)


def _filter_parties_by_decisions(self, parse_result, parties_decisions):
    """Reuse既有 `_dict_to_parsed_parties()` (plural, line 412) — 它把整個 parsed_result
    內 parties[] 轉一批 ParsedParty。我們先 build 一個 sub-dict 只含 use_docx 項目，
    再餵給既有 helper，避免新增單筆 helper / 重複既有邏輯。"""
    decisions_map = {d["party_uid"]: d["action"] for d in (parties_decisions or [])}
    selected_parsed_dicts = []
    for p in parse_result.get("parties", []):
        if decisions_map.get(p["party_uid"]) == "use_docx":
            # parsed_values 是 docx 解析出來的 party dict（與既有 ParsedDocx.parties 元素同 shape）
            selected_parsed_dicts.append(p["parsed_values"])
    if not selected_parsed_dicts:
        return []
    # 用既有 helper 轉換（傳入 parsed_result 形狀的 dict）
    return self._dict_to_parsed_parties({"parties": selected_parsed_dicts})


def _validate_decisions(self, parse_result, decisions, parties_decisions):
    valid_control_ids = {c["control_id"] for c in parse_result.get("matched_controls", [])}
    valid_party_uids = {p["party_uid"] for p in parse_result.get("parties", [])}

    for d in decisions or []:
        if d["control_id"] not in valid_control_ids:
            raise BadRequestError(GrcErrorCode.GRC_DOCX_DECISIONS_INVALID_CONTROL_ID)
    for pd in parties_decisions or []:
        if pd["party_uid"] not in valid_party_uids:
            raise BadRequestError(GrcErrorCode.GRC_DOCX_DECISIONS_INVALID_PARTY_UID)
```

- [ ] **Step 4: Run integration test, PASS**

- [ ] **Step 5: Add validation tests**

```python
def test_invalid_party_uid_returns_400(client, headers, fixture_update_mode_parse):
    payload = {
        "decisions": [],
        "parties_decisions": [{"party_uid": "non-existent-uid", "action": "use_docx"}],
        "content_overrides": {},
    }
    resp = client.post(
        f"/ssp-docx-import/{fixture_update_mode_parse['parse_uid']}/confirm",
        json=payload, headers=headers,
    )
    assert resp.status_code == 400
    assert "GRC_400018" in resp.json["msg"] or "party_uid" in resp.json["msg"]
```

- [ ] **Step 6: Commit**

```bash
git commit -am "feat(ssp-update-diff): wire parties_decisions into confirm flow"
```

---

## Task 2.4: Create-mode regression test

**Files:**
- Create: `tests/test_ssp_docx_import_create_no_diff.py`

確保 create mode 完全不被影響。

- [ ] **Step 1: Write test**

```python
def test_create_mode_returns_has_diff_false(client, headers, fixture_create_mode_parse):
    parse_uid = fixture_create_mode_parse["parse_uid"]
    resp = client.get(f"/ssp-docx-import/{parse_uid}", headers=headers)
    assert resp.status_code == 200
    assert resp.json["has_diff"] is False
    assert resp.json["diff_summary"]["controls"]["total_with_diff"] == 0


def test_create_mode_confirm_writes_all_controls(client, headers, fixture_create_mode_parse):
    """Regression: existing create flow unchanged."""
    parse_uid = fixture_create_mode_parse["parse_uid"]
    decisions = fixture_create_mode_parse["full_use_docx_decisions"]
    resp = client.post(
        f"/ssp-docx-import/{parse_uid}/confirm",
        json={"decisions": decisions, "parties_decisions": [], "content_overrides": {}},
        headers=headers,
    )
    assert resp.status_code == 200
    # 驗證所有 controls 都寫入
    written = query_ssp_control_implementations(fixture_create_mode_parse["ssp_id"])
    assert len(written) == len(decisions)
```

- [ ] **Step 2: Run + verify PASS**

- [ ] **Step 3: Commit**

```bash
git commit -am "test(ssp-update-diff): add create-mode regression test"
```

---

## Task 2.5: Phase 2 Changelog

**Files:**
- Create: `docs/changelog/2026-05-XX-feat-ssp-update-diff-be-phase2.md`

- [ ] 寫 changelog（範本同 Task 1.7，內容換成 Phase 2）+ commit

---

# Phase 3 — FE Pinia Store + Diff 元件

> 階段目標：寫好新元件 + store。Step 2 頁面可獨立顯示 + 互動。整合進 stepper 是 Phase 4。

## Task 3.1: 安裝 jsdiff + 建 textDiff util

**Files:**
- Modify: `compliance-manager-fe/package.json`
- Create: `compliance-manager-fe/src/utils/textDiff.js`
- Create: `compliance-manager-fe/src/utils/__tests__/textDiff.spec.js`

- [ ] **Step 1: 加依賴**

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
npm install diff
```

- [ ] **Step 2: Write failing test**

```javascript
// src/utils/__tests__/textDiff.spec.js
import { describe, it, expect } from 'vitest';
import { computeWordDiff } from '../textDiff';

describe('computeWordDiff', () => {
  it('returns added/removed tokens for changed text', () => {
    const result = computeWordDiff('RBAC 機制', 'ABAC 機制');
    expect(result).toEqual([
      { value: 'RBAC', removed: true, added: false },
      { value: 'ABAC', removed: false, added: true },
      { value: ' 機制', removed: false, added: false },
    ]);
  });

  it('returns single unchanged token when texts match', () => {
    const result = computeWordDiff('same', 'same');
    expect(result.every(t => !t.added && !t.removed)).toBe(true);
  });

  it('falls back to plain blocks when text exceeds 100KB', () => {
    const huge = 'a'.repeat(110_000);
    const result = computeWordDiff(huge, huge + ' new');
    // Fallback shape: just two blocks (full old / full new), no per-word
    expect(result.length).toBeLessThanOrEqual(2);
  });
});
```

- [ ] **Step 3: Run, FAIL**

```bash
npm run test src/utils/__tests__/textDiff.spec.js
```

- [ ] **Step 4: Implement**

```javascript
// src/utils/textDiff.js
import { diffWordsWithSpace } from 'diff';

const FALLBACK_THRESHOLD = 100_000;

export function computeWordDiff(oldText, newText) {
  const a = oldText || '';
  const b = newText || '';
  if (a.length > FALLBACK_THRESHOLD || b.length > FALLBACK_THRESHOLD) {
    console.warn('[textDiff] text exceeds 100KB, falling back to block-level diff');
    return [
      { value: a, removed: true, added: false },
      { value: b, removed: false, added: true },
    ];
  }
  return diffWordsWithSpace(a, b).map(part => ({
    value: part.value,
    added: !!part.added,
    removed: !!part.removed,
  }));
}
```

- [ ] **Step 5: Run, PASS + commit**

```bash
git add package.json package-lock.json src/utils/textDiff.js src/utils/__tests__/textDiff.spec.js
git commit -m "feat(ssp-update-diff): add textDiff util using jsdiff"
```

---

## Task 3.2: 建立 Pinia store

**Files:**
- Create: `compliance-manager-fe/src/stores/ssp-docx-import.js`
- Create: `compliance-manager-fe/src/stores/__tests__/ssp-docx-import.spec.js`

- [ ] **Step 1: Write tests for actions (initDefaultDecisions, setControlDecision, bulkApply, submit payload)**

```javascript
// src/stores/__tests__/ssp-docx-import.spec.js
import { setActivePinia, createPinia } from 'pinia';
import { useSspDocxImportStore } from '../ssp-docx-import';

beforeEach(() => setActivePinia(createPinia()));

describe('initDefaultDecisions', () => {
  it('seeds decisions from parseResult.default_action', () => {
    const store = useSspDocxImportStore();
    store.parseResult = {
      matched_controls: [
        { control_id: 'AC-2', default_action: 'keep_current', objectives: [
          { objective_key: '(a)', default_action: 'use_docx' }
        ]}
      ],
      parties: [{ party_uid: 'p1', default_action: 'keep_current' }],
    };
    store.initDefaultDecisions();
    expect(store.decisions.controls['AC-2'].action).toBe('keep_current');
    expect(store.decisions.controls['AC-2'].objectives['(a)']).toBe('use_docx');
    expect(store.decisions.parties['p1'].action).toBe('keep_current');
  });
});

describe('bulkApply', () => {
  it('sets all controls to given action', () => {
    const store = useSspDocxImportStore();
    store.parseResult = {
      matched_controls: [
        { control_id: 'AC-1', default_action: 'keep_current', objectives: [] },
        { control_id: 'AC-2', default_action: 'keep_current', objectives: [] },
      ],
      parties: [],
    };
    store.initDefaultDecisions();
    store.bulkApply('controls', 'use_docx');
    expect(store.decisions.controls['AC-1'].action).toBe('use_docx');
    expect(store.decisions.controls['AC-2'].action).toBe('use_docx');
  });
});

describe('buildConfirmPayload', () => {
  it('shapes payload as decisions + parties_decisions', () => {
    const store = useSspDocxImportStore();
    store.parseResult = {
      matched_controls: [{ control_id: 'AC-2', default_action: 'use_docx', objectives: [] }],
      parties: [{ party_uid: 'p1', default_action: 'keep_current' }],
    };
    store.initDefaultDecisions();
    const payload = store.buildConfirmPayload();
    expect(payload).toEqual({
      decisions: [{ control_id: 'AC-2', action: 'use_docx', objectives: [] }],
      parties_decisions: [{ party_uid: 'p1', action: 'keep_current' }],
      content_overrides: {},
    });
  });
});
```

- [ ] **Step 2: Run, FAIL**

- [ ] **Step 3: Implement store**

```javascript
// src/stores/ssp-docx-import.js
import { defineStore } from 'pinia';

export const useSspDocxImportStore = defineStore('sspDocxImport', {
  state: () => ({
    parseUid: null,
    mode: 'create',
    parseResult: null,
    decisions: {
      controls: {},   // { [control_id]: { action, objectives: { [key]: action } } }
      parties: {},    // { [party_uid]: { action } }
    },
    contentOverrides: {},
    filters: {
      controls: 'pending',
      parties: 'pending',
    },
  }),
  actions: {
    setParseResult(result) {
      this.parseResult = result;
      this.parseUid = result.parse_uid;
      this.mode = result.mode;
    },
    initDefaultDecisions() {
      const decisions = { controls: {}, parties: {} };
      for (const ctrl of (this.parseResult?.matched_controls || [])) {
        const objMap = {};
        for (const obj of (ctrl.objectives || [])) {
          objMap[obj.objective_key] = obj.default_action || 'keep_current';
        }
        decisions.controls[ctrl.control_id] = {
          action: ctrl.default_action || 'keep_current',
          objectives: objMap,
        };
      }
      for (const p of (this.parseResult?.parties || [])) {
        decisions.parties[p.party_uid] = { action: p.default_action || 'keep_current' };
      }
      this.decisions = decisions;
    },
    setControlDecision(controlId, action) {
      if (!this.decisions.controls[controlId]) {
        this.decisions.controls[controlId] = { action, objectives: {} };
      } else {
        this.decisions.controls[controlId].action = action;
      }
    },
    setObjectiveDecision(controlId, objectiveKey, action) {
      if (!this.decisions.controls[controlId]) return;
      this.decisions.controls[controlId].objectives[objectiveKey] = action;
    },
    setPartyDecision(partyUid, action) {
      this.decisions.parties[partyUid] = { action };
    },
    bulkApply(section, action) {
      if (section === 'controls') {
        for (const id of Object.keys(this.decisions.controls)) {
          this.decisions.controls[id].action = action;
        }
      } else if (section === 'parties') {
        for (const uid of Object.keys(this.decisions.parties)) {
          this.decisions.parties[uid] = { action };
        }
      }
    },
    setFilter(section, filter) {
      this.filters[section] = filter;
    },
    buildConfirmPayload() {
      const decisions = Object.entries(this.decisions.controls).map(([control_id, d]) => ({
        control_id,
        action: d.action,
        objectives: Object.entries(d.objectives).map(([objective_key, action]) => ({
          objective_key, action,
        })),
      }));
      const parties_decisions = Object.entries(this.decisions.parties).map(([party_uid, d]) => ({
        party_uid, action: d.action,
      }));
      return { decisions, parties_decisions, content_overrides: this.contentOverrides };
    },
  },
});
```

- [ ] **Step 4: Run, PASS + commit**

```bash
git commit -am "feat(ssp-update-diff): add Pinia store ssp-docx-import"
```

---

## Task 3.3: 建 DiffSectionShell.vue (shared header)

**Files:**
- Create: `compliance-manager-fe/src/components/grc/ssp-docx-import-v2/diff/DiffSectionShell.vue`

統一各 section 的標題、篩選器、批次按鈕。

- [ ] **Step 1: Implement**（FE 元件 v1 不寫單元測試，只 manual smoke）

```vue
<template>
  <div class="diff-section">
    <header class="diff-section-header">
      <div class="title-block">
        <h3>{{ title }}</h3>
        <Tag v-if="badge" :value="badge" :severity="badgeSeverity" />
      </div>
      <div class="actions">
        <Dropdown
          :modelValue="filter"
          :options="filterOptions"
          optionLabel="label"
          optionValue="value"
          @update:modelValue="$emit('update:filter', $event)"
        />
        <Button
          label="全部採用新值"
          severity="success"
          outlined
          @click="confirmBulk('use_docx')"
        />
        <Button
          label="全部保留現值"
          severity="secondary"
          outlined
          @click="confirmBulk('keep_current')"
        />
        <Button
          icon="pi pi-chevron-down"
          text
          @click="$emit('toggle-collapsed')"
        />
      </div>
    </header>
    <main v-if="!collapsed">
      <slot />
    </main>
  </div>
</template>

<script setup>
import { useConfirm } from 'primevue/useconfirm';
import Dropdown from 'primevue/dropdown';
import Button from 'primevue/button';
import Tag from 'primevue/tag';

const props = defineProps({
  title: { type: String, required: true },
  badge: String,
  badgeSeverity: { type: String, default: 'info' },
  filter: { type: String, default: 'pending' },
  collapsed: Boolean,
});
const emit = defineEmits(['update:filter', 'bulk-apply', 'toggle-collapsed']);

const confirm = useConfirm();
const filterOptions = [
  { label: '全部', value: 'all' },
  { label: '已修改', value: 'changed' },
  { label: '新增', value: 'added' },
  { label: '待決策', value: 'pending' },
];

function confirmBulk(action) {
  const label = action === 'use_docx' ? '採用新值' : '保留現值';
  confirm.require({
    message: `確定將此區所有項目套用「${label}」？`,
    header: '批次套用確認',
    icon: 'pi pi-exclamation-triangle',
    accept: () => emit('bulk-apply', action),
  });
}
</script>
```

- [ ] **Step 2: Manual smoke**：在 storybook 或 dev 頁掛一個 `<DiffSectionShell title="測試" badge="3 處差異" />`，確認 header 顯示

- [ ] **Step 3: Commit**

```bash
git commit -am "feat(ssp-update-diff): add DiffSectionShell.vue"
```

---

## Task 3.4: 建 ControlDiffCard.vue + ObjectiveDiffRow.vue

**Files:**
- Create: `compliance-manager-fe/src/components/grc/ssp-docx-import-v2/diff/ControlDiffCard.vue`
- Create: `compliance-manager-fe/src/components/grc/ssp-docx-import-v2/diff/ObjectiveDiffRow.vue`

side-by-side 並排卡 + word-level highlight + 決策 pills。設計參考 brainstorm `diff-styles-v2.html` Option A。

- [ ] **Step 1: Implement ControlDiffCard.vue**

```vue
<template>
  <div class="control-diff-card" :class="{ 'is-selected': false }">
    <header class="card-toolbar">
      <div class="control-info">
        <span class="control-id">{{ control.control_id }}</span>
        <span class="control-name">{{ control.control_name }}</span>
        <Tag :value="diffStatusLabel" :severity="diffStatusSeverity" />
      </div>
      <SelectButton
        :modelValue="action"
        :options="actionOptions"
        optionLabel="label"
        optionValue="value"
        @update:modelValue="$emit('update:action', $event)"
      />
    </header>

    <div class="card-body">
      <div class="sbs">
        <div class="sbs-pane">
          <div class="sbs-head old">現值（DB）</div>
          <div class="sbs-body" v-html="renderDiff(currentText, parsedText, 'left')" />
        </div>
        <div class="sbs-pane">
          <div class="sbs-head new">新值（docx）</div>
          <div class="sbs-body" v-html="renderDiff(currentText, parsedText, 'right')" />
        </div>
      </div>

      <div v-if="control.objectives?.length" class="objectives">
        <ObjectiveDiffRow
          v-for="obj in control.objectives"
          :key="obj.objective_key"
          :objective="obj"
          :action="objectiveActions[obj.objective_key]"
          @update:action="$emit('update:objective-action', { key: obj.objective_key, action: $event })"
        />
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue';
import { computeWordDiff } from '@/utils/textDiff';
import ObjectiveDiffRow from './ObjectiveDiffRow.vue';
import Tag from 'primevue/tag';
import SelectButton from 'primevue/selectbutton';

const props = defineProps({
  control: { type: Object, required: true },
  action: { type: String, required: true },
  objectiveActions: { type: Object, default: () => ({}) },
});
defineEmits(['update:action', 'update:objective-action']);

const currentText = computed(() => props.control.current_implementation_description || '');
const parsedText = computed(() => props.control.parsed_implementation_description || '');

const diffStatusLabel = computed(() => ({
  changed: '已修改', added: '新增',
}[props.control.diff_status] || ''));
const diffStatusSeverity = computed(() => ({
  changed: 'warning', added: 'success',
}[props.control.diff_status] || 'info'));

const actionOptions = computed(() => {
  const opts = [
    { label: '採用新值', value: 'use_docx' },
    { label: '跳過', value: 'skip' },
  ];
  if (props.control.diff_status !== 'added') {
    opts.unshift({ label: '保留現值', value: 'keep_current' });
  }
  return opts;
});

function renderDiff(oldT, newT, side) {
  const tokens = computeWordDiff(oldT, newT);
  return tokens.map(t => {
    if (side === 'left' && t.added) return ''; // hide additions on left
    if (side === 'right' && t.removed) return ''; // hide removals on right
    if (t.added) return `<span class="hl-add">${escapeHtml(t.value)}</span>`;
    if (t.removed) return `<span class="hl-del">${escapeHtml(t.value)}</span>`;
    return escapeHtml(t.value);
  }).join('');
}

function escapeHtml(s) {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
</script>

<style scoped>
.sbs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.sbs-pane { border: 1px solid var(--surface-border); border-radius: 8px; overflow: hidden; }
.sbs-head { padding: 6px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.sbs-head.old { background: #fef2f2; color: #991b1b; }
.sbs-head.new { background: #ecfdf5; color: #065f46; }
.sbs-body { padding: 12px; line-height: 1.65; }
:deep(.hl-del) { background: #fee2e2; color: #7f1d1d; padding: 0 2px; border-radius: 2px; }
:deep(.hl-add) { background: #d1fae5; color: #064e3b; padding: 0 2px; border-radius: 2px; }
</style>
```

- [ ] **Step 2: Implement ObjectiveDiffRow.vue**（類似 ControlDiffCard 但更精簡，無 SBS header、僅 inline 紅綠 highlight）

- [ ] **Step 3: Manual smoke**：在 dev page 掛一張卡（用 mock data）確認 word-level highlight 工作

- [ ] **Step 4: Commit**

```bash
git commit -am "feat(ssp-update-diff): add ControlDiffCard + ObjectiveDiffRow"
```

---

## Task 3.5: 建 PartyDiffCard.vue + PartiesDiffSection.vue

**Files:**
- Create: `compliance-manager-fe/src/components/grc/ssp-docx-import-v2/diff/PartyDiffCard.vue`
- Create: `compliance-manager-fe/src/components/grc/ssp-docx-import-v2/diff/PartiesDiffSection.vue`

依設計 §5.1，每人一張卡 + side-by-side（每欄 name/email/role/matched_user 一列）。設計參考 brainstorm `parties-diff-layout.html` Layout 1。

- [ ] **Step 1: Implement** — 類似 ControlDiffCard 結構，但 SBS 內容是 4 行 field rows（不是 free text），有變動的欄位給 hl-del/hl-add 樣式
- [ ] **Step 2: Implement PartiesDiffSection.vue 包裝（使用 DiffSectionShell + v-for PartyDiffCard）**
- [ ] **Step 3: Smoke + Commit**

---

## Task 3.6: 建 ControlDiffSection.vue + DiffResolutionStep.vue 包裝

**Files:**
- Create: `.../diff/ControlDiffSection.vue`
- Create: `.../diff/DiffResolutionStep.vue`

- [ ] **Step 1: ControlDiffSection.vue** — 用 DiffSectionShell 包 + v-for ControlDiffCard，依 store filter 過濾
- [ ] **Step 2: DiffResolutionStep.vue** — 包 ControlDiffSection + PartiesDiffSection，加上「上一步 / 下一步」按鈕
- [ ] **Step 3: Manual smoke + Commit**

```bash
git commit -am "feat(ssp-update-diff): add DiffResolutionStep + ControlDiffSection"
```

---

## Task 3.7: Phase 3 Changelog

- [ ] 寫 changelog 紀錄 FE 新元件 + commit

---

# Phase 4 — FE 整合（既有 Step 3 readonly + Stepper progression）

> 階段目標：把 Step 2 接進 SspDocxImportPage 的 stepper，既有 Step 3 元件接收 decisions 進入 readonly。

## Task 4.1: SspDocxImportPage.vue stepper 整合

**Files:**
- Modify: `compliance-manager-fe/src/components/grc/ssp-docx-import-v2/SspDocxImportPage.vue`

- [ ] **Step 1: 讀取既有檔案，找 stepper / 條件 render 區塊**
- [ ] **Step 2: 加新 step（永遠 render 4 panel，內層 v-if 切換）**

> **注意**：PrimeVue Stepper 在 mount 時讀取 child slots，`v-if` 直接套 `<StepperPanel>` 會在面板顯示/隱藏時打亂 active step index。改為**永遠 render 4 panel**，當 `!has_diff` 時：(a) Step 2 內 render 一個自動 advance 的 placeholder；或 (b) 在 router/onMount logic 自動跳過 Step 2。

```vue
<Stepper :activeStep="currentStep">
  <StepperPanel header="上傳"><UploadStep /></StepperPanel>
  <StepperPanel header="解決差異">
    <template v-if="store.parseResult?.has_diff && store.mode === 'update'">
      <DiffResolutionStep />
    </template>
    <template v-else>
      <!-- create mode 或無差異 — 在 watch(parseResult) 內自動把 currentStep 從 1 跳到 2 -->
      <div class="diff-skip-placeholder">無差異，跳到下一步…</div>
    </template>
  </StepperPanel>
  <StepperPanel header="編輯內容"><ContentAdjustmentStep /></StepperPanel>
  <StepperPanel header="確認"><ConfirmStep /></StepperPanel>
</Stepper>
```

並在 setup 內加：
```javascript
watch(() => store.parseResult, (result) => {
  if (result && (!result.has_diff || store.mode !== 'update') && currentStep.value === 1) {
    currentStep.value = 2;  // skip Step 2
  }
});
```

- [ ] **Step 3: Manual E2E 試跑** — 上傳 update mode docx，確認跳出 Step 2 解決頁；create mode 跳過 Step 2

- [ ] **Step 4: Commit**

```bash
git commit -am "feat(ssp-update-diff): integrate DiffResolutionStep into stepper"
```

---

## Task 4.2: ControlImplSection.vue readonly mode

**Files:**
- Modify: `.../ControlImplSection.vue`

- [ ] **Step 1: Inject store / 接 decisions prop**

```vue
<script setup>
import { useSspDocxImportStore } from '@/stores/ssp-docx-import';
const store = useSspDocxImportStore();

const visibleControls = computed(() =>
  props.matchedControls.filter(c => {
    const action = store.decisions.controls[c.control_id]?.action;
    return action !== 'skip';
  })
);

function isReadonly(controlId) {
  return store.decisions.controls[controlId]?.action === 'keep_current';
}
</script>
```

- [ ] **Step 2: Template 上 input 加 `:readonly="isReadonly(c.control_id)"` + 旁邊加 badge `<Tag v-if="isReadonly" value="保留現值" />`**

- [ ] **Step 3: Manual smoke**：跳到 Step 3，確認被勾「保留現值」的條文 input readonly + 有 badge；勾「跳過」的不顯示

- [ ] **Step 4: Commit**

```bash
git commit -am "feat(ssp-update-diff): ControlImplSection respects diff decisions"
```

---

## Task 4.3: PartiesSection.vue readonly mode

**Files:**
- Modify: `.../PartiesSection.vue`

- [ ] 同 4.2 模式（讀 store.decisions.parties，過濾 + readonly）+ commit

---

## Task 4.4: ImportOutline.vue 顯示 diff_summary

**Files:**
- Modify: `.../ImportOutline.vue`

- [ ] 在既有 outline 加 diff summary 區塊：`x 條控制項有差異 / x 個 parties 變動`

- [ ] Commit

---

## Task 4.5: Phase 4 Changelog

- [ ] 寫 changelog + commit

---

# Phase 5 — E2E (compliance-manager-test repo)

> 階段目標：在獨立 test repo 寫 4 條 BDD scenario 確認端到端流程。

## Task 5.1: 寫 requirement + feature 檔

**Files:**
- Create: `compliance-manager-test/requirements/ssp-update-diff.md`
- Create: `compliance-manager-test/specs/ssp-update-diff.feature`

- [ ] 內容依 design.md §7.4 那 4 條 Gherkin scenario
- [ ] Commit (在 test repo)

---

## Task 5.2: 寫 Page Object

**Files:**
- Create: `compliance-manager-test/features/pages/SspDocxImportDiffPage.js`

- [ ] 封裝 Step 2 頁面操作：upload / 等 stepper / 切換 action / 點下一步 / 點批次
- [ ] Commit

---

## Task 5.3: 寫 Step Definitions

**Files:**
- Create: `compliance-manager-test/features/steps/ssp-update-diff.steps.js`

- [ ] 實作 Given/When/Then steps
- [ ] 跑 E2E：`npm run test:e2e -- --tags @ssp-update-diff`
- [ ] 修到全綠
- [ ] Commit

---

## Task 5.4: 全 stack smoke

- [ ] BE 起來、FE 起來、test repo 跑 e2e — 4 scenarios PASS
- [ ] 寫最終 release-notes 或 follow-up changelog

---

# 完成檢查清單

- [ ] BE diff service 單元測試 ≥ 90% 覆蓋
- [ ] BE integration test：update mode + create mode regression 都 PASS
- [ ] FE component 至少 smoke render ok
- [ ] Pinia store unit test PASS
- [ ] E2E 4 scenarios PASS
- [ ] design.md / implementation-plan.md / 5 個 phase changelog 齊備
- [ ] 既有 create flow 完全不變（regression test 守住）
- [ ] CLAUDE.md DDD 規範：route 不查 DB / app service `@transaction` / repo session lazy ✓
- [ ] CLAUDE.md error code 規範：序號正確分配 / 沿用既有 not-found code ✓
- [ ] 文件路徑正確：`docs/features/FR-025-2605-ssp-update-diff/{design,implementation-plan}.md` ✓

---

# Open Issues / Plan-Stage Verification 待辦

依 design §10：

1. **`ResponsiblePartyQueryEntity` 欄位名稱確認** — Phase 1 Task 1.6 用 `list_by(ResponsiblePartyQueryEntity(context_type=..., context_id=...))` 標準 pattern。實作前 grep 確認 entity class 實際支援 `context_type` / `context_id` 欄位；若名稱不同（例如 `target_type` / `target_id`）依實際定義調整
2. **`_load_current_parties` 回傳結構與既有 `parsed_parties` 結構對齊** — 確保 `match_parties` 比對 key 一致
3. **既有 `_dict_to_parsed_party()` 是否可重用** — Phase 2 Task 2.3 filter 完寫入時，把 dict 轉 ParsedParty 用既有 helper（grep `_dict_to_parsed_part`）
