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

文件版本:1.2 撰寫日期:2026-05-06(初版);最後修訂:2026-05-07(review 後對齊) 文件性質:SD — System Design(架構與實作策略) Raw requirement./raw-requirement.md 狀態:已實作完成(BE 35 + FE 60 + Test 1 = 96 commits);code review (Critical 6 / Important 12) 已修;§11 記錄所有與設計的偏差(含 review 後新增 §11.13–11.16)。

⚠️ 閱讀注意:本文件保留原始 brainstorm 設計(§1–§10)作為歷史脈絡。實作中有若干修正,各節內文已加 (updated 2026-05-07: see §11.x) 標記,詳細偏差原因請見 §11。

設計守則:

  1. 不加新表、不改既有 endpoint URL,只擴充 parse / confirm 的 request/response schema
  2. 不修改 jedi-oscal 套件,新邏輯落在主專案
  3. 沿用既有 SspDocxImportPage.vue 元件結構,Step 2 為新插入步驟、Step 3 為既有元件微改
  4. 嚴守 CLAUDE.md DDD 層級規範(Route 不查 DB / App Service @transaction / Repo session lazy / 權限檢查在 app service 層)
  5. Smart default 邏輯落在 BE,FE 只渲染 + 收集 user 決策

0. 問題背景

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

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


1. 整體流程

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

核心原則:

  • 既有 parse + confirm endpoint 簽名不變,schema 擴充
  • Step 2 是新頁;Step 3 是既有頁微改(接收 decisions 過濾)
  • 「DB 有但 docx 沒帶」自然不出現在 diff 頁,原樣保留
  • 完全相同的項目也不出現在 diff 頁

2. Diff 範圍

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

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

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


3. Smart Default 邏輯(§2.5 序列化)

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

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

原始設計(Phase 1 brainstorm):

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

實際行為(shipped v1):

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

FE 責任分攤:BE annotate 保留所有項目(不做 filter);Step 2 FE 過濾 unchanged 不顯示;Step 3 使用完整清單(含 unchanged)以正確呈現預覽。

邏輯位置app/oscal/service/ssp_docx_diff_service.py(純邏輯類)+ ssp_docx_import_app_service.py 內調用。


4. Data Model & BE 變更

4.1 Parse Response 擴充(GET /ssp-docx-import/<parse_uid>

(updated 2026-05-07: see §11.2) — BE annotate 保留所有項目(diff_status 不限),不預先 filter。FE Step 2 按 diff_status filter 顯示;Step 3 使用完整清單。

(updated 2026-05-07)mode 欄位值為 "full" / "statement_only"(代表 parse mode),不是 "create" / "update"。FE 偵測「是否需 Step 2」依 diff_summary.controls.changed > 0 或其他 section changed/added/gone > 0 判斷。

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

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

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

4.2 Confirm Payload 擴充(POST /ssp-docx-import/<parse_uid>/confirm

action 三值語意(沿用既有 ssp-doc-parser):

  • "use_docx" — 採用新值寫入 DB
  • "keep_current" — 保留現值(DB 不動)
  • "skip" — 跳過此項(DB 不動,且編輯頁不顯示)

diff_status="added" 項目(current 為空):

  • keep_currentskip 行為等價(兩者都不寫入),FE 可只渲染 [採用新值 / 跳過] 兩按鈕,BE 收到任一視為不寫入即可
{
  "decisions": [
    {
      "control_id": "AC-2",
      "action": "use_docx",                                          // 既有
      "objectives": [
        { "objective_key": "(a)", "action": "keep_current" }         // 既有
      ]
    }
  ],
  "parties_decisions": [                                             // 新增
    { "party_uid": "...", "action": "use_docx" }
  ],
  "content_overrides": { ... }                                       // 既有
}

4.3 BE Service 層變更

檔案:app/oscal/service/ssp_docx_import_app_service.py

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

4.4 Strategy 層變更

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

(updated 2026-05-07: see §11.5)ParsedParty 新增 target_party_uid 欄位,讓 strategy 能用精準 uid 更新已比對到的 party(而非靠 name 查找 — name 有可能已改,造成 append 新 party 的 bug)。

(updated 2026-05-07: see §11.12)_upsert_responsible_party() 對已存在的 link record(同 party_uid + role + context 組合)採 idempotent return(ResponsiblePartyEntityuid 屬性,update path 無法成立)。

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

呼叫順序(app service confirm 內):

filtered_controls = filter_by_decisions(parsed_controls, decisions)
strategy.write(filtered_controls, content_overrides)        # 既有

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

4.5 Domain / Infra 層變更

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

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

4.6a Email Field Aliasing(新增 2026-05-07)

(see §11.7)

DB current parties dict 使用 email 欄位名;parsed parties(from docx parser)使用 email_addressssp_docx_diff_service.py_get_party_field() helper 統一 normalize 兩個 key name,確保 diff 比對不因欄位名差異誤判。

4.6b Party-type-aware Diff(新增 2026-05-07)

(see §11.8)

原設計對所有 party 統一比對 5 個欄位(name / email / role / matched_user_id / matched_org_unit_id)。實作後改為 party_type-aware:

  • person:不比 matched_org_unit_id
  • organization:不比 matched_user_id

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

4.6c DB-only Objectives 合成(新增 2026-05-07)

(see §11.9)

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

4.6d Party Enrichment(新增 2026-05-07)

(see §11.6)

原設計 parties response 只帶 matched_user_id(整數 ID)。實作中發現這是資安問題(暴露內部 ID)且 FE 無法顯示對人名。BE 新增 _enrich_party_user_org_names() 批次查詢:

  • matched_user_id → 填入 matched_user_nickname / matched_user_login_name
  • matched_org_unit_id → 填入 matched_org_unit_name

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

4.6e Module Frame Source Type Support(新增 2026-05-07)

(see §11.11)

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

4.8 Backward Compat

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

5. FE 元件結構

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

5.1 元件樹

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

5.2 共用:Word-level Diff 計算

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

5.3 State Management — 新建 Pinia Store

src/stores/ssp-docx-import.js

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

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

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

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

5.4 Routing

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

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

5.5 既有 Step 3 元件改動範圍

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

5.6 視覺風格定案

依 brainstorming visual 階段確認:

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

6. 邊界情境 & 錯誤處理

6.1 Edge Cases

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

新增 edge cases(updated 2026-05-07: see §11.10):

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

6.2 錯誤處理(CLAUDE.md error code 規範)

沿用既有 ssp-doc-parser 已定義之 error code(避免重複):

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

本 feature 新增:

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

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

6.3 寫入交易性

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

6.4 權限檢查

依既有 import-docx 權限:parse / confirm 必須 manager 角色(在 SSP 所屬 project)。

  • 檢查層級:app service 層透過 participant_domain_service.get_one() → 確認 role=manager
  • 不在 route 層查 DB(CLAUDE.md DDD 規範)

6.5 Diff 計算的失敗模式

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

7. 測試策略

7.1 BE Unit Tests(compliance-manager-be/test/

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

7.2 BE Integration Tests(test/integration/

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

7.3 FE Component Tests(compliance-manager-fe/

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

7.4 E2E(compliance-manager-test/

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

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

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

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

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

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

新增檔案位置(依 compliance-manager-test 既有結構):

  • requirements/ssp-update-diff.md
  • specs/ssp-update-diff.feature
  • features/steps/ssp-update-diff.steps.js
  • features/pages/SspDocxImportDiffPage.js

7.5 測試覆蓋目標

覆蓋率
BE 新方法(_compute_default_actions / _load_current_parties / write_parties ≥ 90%
FE 新元件 smoke test(render OK + key interactions)
E2E 上述 4 條 scenario

7.6 Test Plan 階段

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


8. 開發順序建議(草擬,待 implementation plan 細化)

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

9. Brainstorming 決策紀錄(追溯用)

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

10. 開放議題(implementation plan 階段需釐清)

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

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

狀態:✅ 已實作完成(2026-05-07)。詳見 §11 偏差記錄 + docs/conversation-history/2026-05-06-ssp-update-diff/


11. Implementation Reality / Reconciliation(2026-05-07)

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

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


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

原始設計:added items 預設 use_docx(自動匯入)。

實際行為:added items 的 default_action = null;user 必須明確選擇才算決策。

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


11.2 BE annotate 保留所有項目(不 filter)

原始設計:BE filter 掉 unchanged / gone,只回傳有差異的項目。

實際行為:BE annotate 方法保留所有項目(所有 diff_status);FE Step 2 自行 filter unchanged 不顯示;Step 3 使用完整清單。

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


11.3 gone 項目改為 user 可決策(非 silent preserve)

原始設計gone(DB 有但 docx 沒帶)filter 掉,原樣保留不顯示給 user。

實際行為gone 項目出現在 Step 2,預設 keep_current,user 可切換為 use_docx

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

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

Hard delete 限制oscal_responsible_parties schema 沒 is_active / 無 audit columns,移除即不可復原(v1 接受此限制;如需 undo 需 schema migration)。

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

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


11.4 Filter 從 5 選項簡化為 3(全部 / 待決策 / 已決策)

原始設計:5 個篩選器(全部 / 已修改 / 新增 / 將清空 / 待決策)。

實際行為:3 個(全部 / 待決策 / 已決策),兩者互斥語意:

  • 「待決策」= 該 control 下有任何 decision item 仍在 default_action(未 user-touch)
  • 「已決策」= 該 control 下所有 decision items(impl + 每個 AO)都已被 user touch(action !== default)

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


11.5 target_party_uid 加到 ParsedParty

原始設計_upsert_party()(party_type, name) 查找。

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

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


11.6 Party enrichment with nickname / login_name

原始設計:parties response 含 matched_user_id(整數 ID)。

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

原因:暴露 internal user_id 是資安問題(雖然前後端同 domain,仍不應 leak);且 FE 無法將 user_id 還原成顯示名,需 BE 提供可讀名稱。


11.7 Email field aliasing(email vs email_address

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

實際行為:DB current parties dict 用 email;docx parser 產出的 parsed parties 用 email_addressssp_docx_diff_service.py_get_party_field() 統一 normalize 兩個 key name。

原因:DB schema 歷史遺留 email 欄位名;jedi-oscal parser 產出的 intermediate 用 email_address(OSCAL 欄位名)。兩邊都不改,在 diff service 中間層 normalize。


11.8 Party_type-aware diff fields

原始設計:統一比對 5 個欄位(name / email / role / matched_user_id / matched_org_unit_id)。

實際行為person 不比 matched_org_unit_idorganization 不比 matched_user_id

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


11.9 DB-only objectives 合成為 gone

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

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

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


11.10 PrimeVue 3.53 版本特定細節

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

實際踩到的三個雷:

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

11.11 Module_frame source type support

原始設計_load_current_parties 只處理 project_ssp source。

實際行為_load_current_partiessource_type 分支:若 module_frame,查詢 responsible_party with context_type='module_frame' + 對應 context_id

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


原始設計(implementation-plan):隱含假設 _load_current_parties 可以從 ResponsiblePartyEntity 直接讀 name / email_address 等欄位。

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

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


原始設計:parties gone + use_docx_filter_parties_for_write silent log + drop(沒實際 delete path),FE Step 2 也直接隱藏 gone parties。導致 user 看不到「DB 有但 docx 沒帶」的 party,無法決定要不要清掉。

實際行為

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

11.14 Adapter failure surfacing(review I9,2026-05-07)

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

實際行為

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

11.15 Stepper 視覺固定 by mode(2026-05-07)

原始設計:3 步 stepper 永遠存在,BE 用 mode === 'update' 判斷是否需 Step 2(已知為錯誤的 mode 比對 — §11.4)。

實際行為

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

11.16 Code review critical / important 修正(2026-05-07)

完整 review 報告:docs/review/2026-05-07-ssp-update-diff-review.md(5-agent parallel review)。

Critical 6 全部修完(C1-C6)

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

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