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

⚠️ 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)。


§1

Pre-Implementation Verification

V1: 確認 responsible_party / parties infrastructure 已存在

# 確認 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 最新序號

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_400015GRC_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 佔用。


§2

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 暫不改。

§3

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。

# 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)
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

# 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")
pytest tests/test_ssp_docx_diff_service.py::TestComputeDefaultActionForText -v

Expected: 5 PASS

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"

§4

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。

# 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
pytest tests/test_ssp_docx_diff_service.py::TestMatchParties -v

Expected: 3 FAIL

# 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
pytest tests/test_ssp_docx_diff_service.py::TestMatchParties -v

Expected: 3 PASS

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"

§5

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。

# 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")
pytest tests/test_ssp_docx_diff_service.py::TestComputePartyDiff -v
# 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)
git commit -am "feat(ssp-update-diff): add compute_party_diff"

§6

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 統計。

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
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),
    }
git commit -am "feat(ssp-update-diff): add build_diff_summary"

§7

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 的項目)。

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
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
git commit -am "feat(ssp-update-diff): add annotate_parse_result entrypoint"

§8

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 加新欄位。

# 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,  # 新增
)
# 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 定義調整。

找到既有 get_parse_result() 方法(grep def get_parse_result),在 return 前插入:

@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
# 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)

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

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

§9

Task 1.7: Phase 1 Changelog

Files:

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

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

---
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
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()

§10

Task 2.1: 新增 GRC error codes

Files:

  • Modify: common/code/grc_error_code.py
# 在既有 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")
git commit -am "feat(ssp-update-diff): add 3 GRC error codes for confirm validation"

§11

Task 2.2: 擴充 confirm payload schema (parties_decisions)

Files:

  • Modify: api/oscal/routes/ssp/ssp_docx_import_route.py

找到既有 SspDocxImportConfirmRoute 內的 use_kwargs schema(grep parties_decisions\|class.*ConfirmSchema):

# 在既有 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"]),
    )
git commit -am "feat(ssp-update-diff): extend confirm payload with parties_decisions"

§12

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
# 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。

pytest tests/test_ssp_docx_import_diff_flow.py -v
# 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)
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"]
git commit -am "feat(ssp-update-diff): wire parties_decisions into confirm flow"

§13

Task 2.4: Create-mode regression test

Files:

  • Create: tests/test_ssp_docx_import_create_no_diff.py

確保 create mode 完全不被影響。

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)
git commit -am "test(ssp-update-diff): add create-mode regression test"

§14

Task 2.5: Phase 2 Changelog

Files:

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

Phase 3 — FE Pinia Store + Diff 元件

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

§15

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
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
npm install diff
// 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);
  });
});
npm run test src/utils/__tests__/textDiff.spec.js
// 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,
  }));
}
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"

§16

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
// 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: {},
    });
  });
});
// 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 };
    },
  },
});
git commit -am "feat(ssp-update-diff): add Pinia store ssp-docx-import"

§17

Task 3.3: 建 DiffSectionShell.vue (shared header)

Files:

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

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

<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>
git commit -am "feat(ssp-update-diff): add DiffSectionShell.vue"

§18

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。

<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>
git commit -am "feat(ssp-update-diff): add ControlDiffCard + ObjectiveDiffRow"

§19

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。


§20

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

Files:

  • Create: .../diff/ControlDiffSection.vue
  • Create: .../diff/DiffResolutionStep.vue
git commit -am "feat(ssp-update-diff): add DiffResolutionStep + ControlDiffSection"

§21

Task 3.7: Phase 3 Changelog


Phase 4 — FE 整合(既有 Step 3 readonly + Stepper progression)

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

§22

Task 4.1: SspDocxImportPage.vue stepper 整合

Files:

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

注意: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。

<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 內加:

watch(() => store.parseResult, (result) => {
  if (result && (!result.has_diff || store.mode !== 'update') && currentStep.value === 1) {
    currentStep.value = 2;  // skip Step 2
  }
});
git commit -am "feat(ssp-update-diff): integrate DiffResolutionStep into stepper"

§23

Task 4.2: ControlImplSection.vue readonly mode

Files:

  • Modify: .../ControlImplSection.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>
git commit -am "feat(ssp-update-diff): ControlImplSection respects diff decisions"

§24

Task 4.3: PartiesSection.vue readonly mode

Files:

  • Modify: .../PartiesSection.vue

§25

Task 4.4: ImportOutline.vue 顯示 diff_summary

Files:

  • Modify: .../ImportOutline.vue

§26

Task 4.5: Phase 4 Changelog


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

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

§27

Task 5.1: 寫 requirement + feature 檔

Files:

  • Create: compliance-manager-test/requirements/ssp-update-diff.md
  • Create: compliance-manager-test/specs/ssp-update-diff.feature

§28

Task 5.2: 寫 Page Object

Files:

  • Create: compliance-manager-test/features/pages/SspDocxImportDiffPage.js

§29

Task 5.3: 寫 Step Definitions

Files:

  • Create: compliance-manager-test/features/steps/ssp-update-diff.steps.js

§30

Task 5.4: 全 stack smoke


完成檢查清單


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