Bug O Implementation Plan — Docx 重 import diff stepper 4 key 擴展 + TabView refactor

For agentic workers: 跟著本 plan 一個 task 一個 task 跑;每個 phase 完成跑 test → commit → 進下一 phase。

Goal: Docx 重 import (update mode) 進 diff stepper 時,補完 4 個缺漏 key (system_characteristic / components / leveraged_authorizations / inventory_items) 的 per-row decision 機制,並把 DiffResolutionStep 改成 TabView 容器。

Architecture:

  • BE:SspDocxDiffService 加 4 個 annotation method + build_diff_summary 擴展。SspDocxImportAppService.confirm_import_apply_v3_decisions filter 在 _run_v2_bundle_confirm 之前把 decision 套到 parsed_result(含 overwrite caveat — keep_current 必須 inject current row 防 delete)。
  • FE:sspDocxImportStore.decisions 加 4 sub-map + buildConfirmPayload 擴展。新增 4 個 *DiffSection.vue + 4 個 *DiffCard.vue(mirror PartyDiffCard pattern)。DiffResolutionStep.vue 重構成 6-tab TabView。

Tech Stack: Python 3.11 / SQLAlchemy / pytest / Vue 3 / Pinia / PrimeVue 3.53 (TabView / DataTable / Dropdown / SelectButton)


§1

⚠️ 關鍵設計 caveat(開工前必看)

Caveat 1:3 strategy 是 overwrite,不是 incremental upsert

LeveragedWriteStrategy.write / ComponentWriteStrategy.write / InventoryItemWriteStrategy.write 三者都是「delete all by ssp_id → insert from parsed list」(見 domain/oscal/service/write_strategy/{leveraged,component,inventory_item}_write_strategy.py 各自的 "Overwrite semantics" docstring)。

後果:對 docx 重 import 的 decision filter,正確語意是:

diff_status × decision 動作
changed × use_docx parsed_row 進 final list
changed × keep_current current_row 進 final list(不然被 delete)
changed × skip 同 keep_current(保留現值不被 delete)
added × use_docx parsed_row 進 final list
added × skip drop(不寫)
gone × keep_current current_row 進 final list(防 delete)
gone × use_docx drop(accept delete)
unchanged × * current_row 進 final list(沒 decision 但要保留)

所以 _apply_v3_decisions 結尾要 set parsed_result[list_key] = final_list,覆寫掉原本 parsed_result 內的 list。

Caveat 2:current entity → dict 的 shape 要對齊 parsed dict

ParsedComponent / ParsedLeveragedAuthorization / ParsedInventoryItem dataclass field name 跟 DB entity field name 不完全一樣。Inject current row 進 final list 時要 mapper:

Parsed dataclass field Current entity 來源 備註
ParsedComponent.title ComponentEntity.title 對齊
ParsedComponent.component_type 對齊
ParsedComponent.leveraged_authorization_ref 反查 entity.leveraged_authorization_uid → LA.title (uid → title) map reverse-lookup
ParsedComponent.protocol/port_ranges/security_auth entity.props["..."] from JSONB
ParsedLeveragedAuthorization.fedramp_package_id/impact_level/... entity.props["..."] from JSONB
ParsedInventoryItem.asset_id/asset_tag/ipv4_address/... entity.props["..."] from JSONB
ParsedInventoryItem.implemented_component_refs 反查 entity.implemented_component_uids → component.title (uid → title) map

寫 entity → ParsedXxx dict 的 helper(推薦 stateless static method 在 diff service 或 import app service),複用 mapper logic。

Caveat 3:Match key 設計

Key Primary match Tiebreaker 防呆
Components (title.strip().lower(), component_type) 同 (title, type) 重複 → 用第一個(log warning) title + type 為空 → skip pair
LA title.strip().lower() 同 title 重複 → 用第一個 title 為空 → skip pair
Inventory description.strip().lower() 同 description 重複 → 用第一個 description 為空 → skip pair
SC 單筆 by ssp_id — 不 match N/A N/A

Mirror SspDocxDiffService.match_parties 的 dual-index pattern。

Caveat 4:System Characteristic 是單筆 dict 不是 list

  • decisions單一 action(不是 per-row map):system_characteristic_decision: "use_docx" | "keep_current" | "skip"
  • annotation 結構:annotated["system_characteristic"] = {current_values, parsed_values, diff_status, default_action},沒 row_uid 也沒 list
  • _apply_v3_decisions 對 SC 是「決定要不要呼叫 _sc_write_strategy.write」 — use_docx → 寫 parsed;keep_currentskipunchanged → 不寫(DB 保持現值 — SC 是 upsert by ssp_id 不是 overwrite,不寫等於保留)

Caveat 5:FE TabView 用 :active-step 還是 :active-index?

PrimeVue 3.53 TabView prop 是 :active-index="..." (per 既有 docs),但 PrimeVue Steps 才用 :active-step(per memory feedback_primevue_quirks)。Bug O 用的是 TabView 不是 Steps,所以 :active-index 才對。實作前先 read ~/Projects/Billows/Audit-Manager/compliance-manager-fe/CLAUDE.md 對 TabView 的描述。

Caveat 6:BE service 改完必提醒 user 重啟

每 phase BE 改完跑 pytest pass 後,commit 訊息加 「⚠️ 需重啟 BE pid 」(per memory feedback_be_restart_after_service_change)。


§2

Phase O-A:BE diff service 擴展(4 key annotation + summary)

Files:

  • Modify: app/oscal/service/ssp_docx_diff_service.py(既有 290 行,加 ~350 行)
  • Test: tests/test_ssp_docx_diff_service.py(加 ~200 行)

A.0 Pre-flight verify

A.1 Helper: entity → parsed dict mapper

def test_component_entity_to_parsed_dict_roundtrip():
    from unittest.mock import Mock
    from app.oscal.service.ssp_docx_diff_service import SspDocxDiffService
    svc = SspDocxDiffService()
    entity = Mock(
        title="X", component_type="service", description=None, purpose=None,
        status="operational", leveraged_authorization_uid=None,
        props={"protocol": "TCP", "port_ranges": "443", "security_auth": "OAuth"},
    )
    out = svc._component_entity_to_parsed_dict(entity, la_uid_to_title={})
    assert out["title"] == "X"
    assert out["component_type"] == "service"
    assert out["protocol"] == "TCP"
    assert out["leveraged_authorization_ref"] is None

A.2 Match function (component / LA / inventory)

A.3 Per-row diff compute

A.4 Annotation methods (annotate 4 key into result dict)

  • {
      "row_uid": str,  # current entity uid (matched/gone) or "new-<sha1(title+type)>" (added)
      "title": str,    # display name
      "diff_status": "changed" | "added" | "gone" | "unchanged",
      "default_action": "keep_current" | "use_docx" | None,
      "current_values": dict | None,  # entity → parsed dict
      "parsed_values": dict | None,
    }
  • {
      "diff_status": str,
      "default_action": str | None,
      "current_values": dict | None,
      "parsed_values": dict | None,
    }

A.5 build_diff_summary 擴展

A.6 Run all diff service tests + commit

  • git add app/oscal/service/ssp_docx_diff_service.py tests/test_ssp_docx_diff_service.py
    git commit -m "feat(ssp-oscal-alignment): Bug O Phase A — BE diff service 擴展 4 key annotation + summary
    
    - 加 _annotate_components / _leveraged_authorizations / _inventory_items / _system_characteristic
    - 加 match_components / leveraged / inventory(mirror match_parties dual-index pattern)
    - 加 compute_xxx_diff 4 個 per-row compare
    - build_diff_summary 加 4 key counts;annotate_parse_result 加 4 optional param
    - 4 個 entity → parsed dict helper(attempt props JSONB 攤平 + LA/component ref 反查)
    
    Tests: tests/test_ssp_docx_diff_service.py 全 PASS
    Backward compat: annotate_parse_result 4 個 v3 current_* param 預設 None,既有 caller 不受影響
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

§3

Phase O-B:BE confirm 接 4 個新 decisions + decision filter

Files:

  • Modify: app/oscal/service/ssp_docx_import_app_service.py(confirm_import + 新 method _apply_v3_decisions + _load_current_v3_lists
  • Modify: common/code/grc_error_code.py(加 4 個 invalid decision error code)
  • Test: tests/test_ssp_docx_import_app_service.py

B.0 Pre-flight

B.1 加 GrcErrorCode

  • GRC_DOCX_DECISIONS_INVALID_COMPONENT_UID     = ("Component decision 引用 diff 沒有的 row_uid", "GRC_400xxx")
    GRC_DOCX_DECISIONS_INVALID_LA_UID            = ("Leveraged authorization decision 引用 diff 沒有的 row_uid", "GRC_400xxx")
    GRC_DOCX_DECISIONS_INVALID_INVENTORY_UID     = ("Inventory item decision 引用 diff 沒有的 row_uid", "GRC_400xxx")
    GRC_DOCX_DECISIONS_INVALID_SC_ACTION         = ("System characteristic decision action 不合法", "GRC_400xxx")
    序號接續既有 GRC_400 系列

B.2 _load_current_v3_lists helper

    • 注意:對 source_type=module_frame,可能要 resolve mf → template_ssp_id;若無 template SSP yet → 4 個 list 全 empty + sc=None
    • 注意:對 mf 沒 template SSP 的 case,annotate 仍能跑(current = 全 empty → 全 added)

B.3 _validate_v3_decisions

B.4 _apply_v3_decisions(核心 — 含 overwrite caveat)

    • For each list_key in (components, leveraged_authorizations, inventory_items):
      • Build decision_map = {d["row_uid"]: d["action"] for d in decisions}
      • Walk annotated[list_key], for each entry decide using above table (Caveat 1),build final_list
      • Set parsed_result[list_key] = final_list
    • For SC:
      • If sc_decision in {"keep_current", "skip"} OR annotated.sc.diff_status == "unchanged":
        • parsed_result["system_characteristic"] = None(會讓 _dict_to_parsed_system_characteristic 回 None,sc write skip)
      • Else (use_docx): leave parsed_result["system_characteristic"] as-is

B.5 Wire 進 confirm_import

  • components_decisions = payload.get("components_decisions") or []
    la_decisions = payload.get("leveraged_authorizations_decisions") or []
    inventory_decisions = payload.get("inventory_items_decisions") or []
    sc_decision = payload.get("system_characteristic_decision")  # str | None
    • 既有:load current_parties + annotate 只動 controls/parties
    • 改:同時 _load_current_v3_lists + 把 4 個 current_* 餵 annotate_parse_result
    • 改:_validate_decisions 之後加 _validate_v3_decisions

B.6 Run all import service tests + commit

  • git add app/oscal/service/ssp_docx_import_app_service.py tests/test_ssp_docx_import_app_service.py common/code/grc_error_code.py
    git commit -m "feat(ssp-oscal-alignment): Bug O Phase B — BE confirm 接 4 個 v3 decisions filter(含 overwrite caveat)
    
    - confirm_import 讀 4 個新 payload key (components_decisions / la_decisions / inventory_decisions / system_characteristic_decision)
    - _load_current_v3_lists 撈 current 4 key 餵給 annotate_parse_result
    - _validate_v3_decisions 防 FE 送假 row_uid
    - _apply_v3_decisions 把 decision 套到 parsed_result list(注意:3 strategy overwrite 語意 → keep_current 必須 inject current row 防 delete)
    - GrcErrorCode 加 4 個 invalid decision code
    
    Tests: 全 PASS
    Backward compat: 既有 FE caller 沒送 4 個新 key → 行為 = 全 use_docx(既有行為)
    
    ⚠️ 需重啟 BE 讓 fix 生效。
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

§4

Phase O-C:FE store 擴展(decisions schema + 4 sub-map)

Files:

  • Modify: ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/stores/sspDocxImportStore.js(157 → ~280 行)
  • Test: ~/Projects/Billows/Audit-Manager/compliance-manager-fe/tests/... (FE 是否有 store unit test infra?if yes write,if no skip — 待 user 拍板)

C.0 Pre-flight

C.1 擴展 state

  • decisions: {
      controls: {},
      parties: {},
      components: {},           // {row_uid: {action}}
      leveraged_authorizations: {},
      inventory_items: {},
      system_characteristic: { action: null },  // 單筆
    }

C.2 擴展 getters

C.3 initDefaultDecisions

  • for (const c of this.parseResult?.components || []) {
      decisions.components[c.row_uid] = { action: c.default_action ?? (c.diff_status === 'added' ? 'use_docx' : null) }
    }
    // 同 leveraged / inventory
    const scDiff = this.parseResult?.system_characteristic_diff
    if (scDiff) {
      decisions.system_characteristic = { action: scDiff.default_action ?? (scDiff.diff_status === 'added' ? 'use_docx' : null) }
    }
    added/changed/gone default 同 parties pattern(per _defaultPartyAction

C.4 setXxxDecision actions

C.5 bulkApply 擴展

C.6 buildConfirmPayload 擴展

  • return {
      decisions,
      parties_decisions,
      components_decisions: Object.entries(this.decisions.components).map(([row_uid, d]) => ({row_uid, action: _safeAction(d.action)})),
      leveraged_authorizations_decisions: ...,
      inventory_items_decisions: ...,
      system_characteristic_decision: _safeAction(this.decisions.system_characteristic.action),
      content_overrides: this.contentOverrides,
    }

C.7 reset 擴展

C.8 Commit Phase O-C

  • cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
    git add src/stores/sspDocxImportStore.js
    git commit -m "feat(ssp-oscal-alignment): Bug O Phase C — FE store 擴展 4 key decisions schema
    
    - decisions 加 components / leveraged_authorizations / inventory_items / system_characteristic 4 sub-map
    - initDefaultDecisions 加對應 4 key 預設 action 邏輯
    - setXxxDecision / bulkApply / reset 對 4 key 同步擴展
    - buildConfirmPayload 加 4 個新 payload key 送 BE
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

§5

Phase O-D:FE 4 個新 DiffSection + DiffCard 元件

Files (新):

  • Create: ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/components/grc/ssp-docx-import-v2/diff/SystemCharacteristicDiffSection.vue
  • Create: 同上 + ComponentsDiffSection.vue / LeveragedAuthorizationsDiffSection.vue / InventoryItemsDiffSection.vue
  • Create: 同上 + ComponentDiffCard.vue / LeveragedAuthorizationDiffCard.vue / InventoryItemDiffCard.vue
  • 不需要 SystemCharacteristicDiffCard — section 內單筆直接 inline render

D.0 Pre-flight

D.1 ComponentDiffCard.vue(mirror PartyDiffCard)

  • const fields = [
      { key: 'title', label: '名稱' },
      { key: 'component_type', label: '類型' },
      { key: 'status', label: '狀態' },
      { key: 'description', label: '描述' },
      { key: 'purpose', label: '目的' },
      { key: 'leveraged_authorization_ref', label: '關聯外部服務' },
      { key: 'protocol', label: '通訊協定' },
      { key: 'port_ranges', label: 'Port' },
      { key: 'security_auth', label: '驗證方式' },
    ]

D.2 LeveragedAuthorizationDiffCard.vue

  • const fields = [
      { key: 'title', label: '服務名稱' },
      { key: 'fedramp_package_id', label: 'FedRAMP Package ID' },
      { key: 'impact_level', label: 'Impact Level' },
      { key: 'data_types', label: 'Data Types' },
      { key: 'nature_of_agreement', label: '協議性質' },
      { key: 'authorized_users', label: '授權使用者' },
      { key: 'date_authorized', label: '授權日期' },
      { key: 'remarks', label: '備註' },
    ]

D.3 InventoryItemDiffCard.vue

  • const fields = [
      { key: 'description', label: '描述' },
      { key: 'asset_id', label: 'Asset ID' },
      { key: 'asset_tag', label: 'Asset Tag' },
      { key: 'ipv4_address', label: 'IPv4' },
      { key: 'mac_address', label: 'MAC' },
      { key: 'fqdn', label: 'FQDN' },
      { key: 'hostname', label: 'Hostname' },
      { key: 'software_name', label: '軟體名稱' },
      { key: 'os_name', label: 'OS' },
      { key: 'implemented_component_refs', label: '關聯元件', isList: true },
    ]

D.4 3 個 DiffSection(mirror PartiesDiffSection)

D.5 SystemCharacteristicDiffSection.vue(單筆,不複用 DiffSectionShell)

  • <template>
      <DiffSectionShell title="受評標的 (System Characteristic)" :badge="badgeText" badge-severity="warning" :collapsed="collapsed" :filter="'all'" @toggle-collapsed="collapsed=!collapsed" @bulk-apply="onBulkApply">
        <div v-if="scDiff && scDiff.diff_status !== 'unchanged'" class="sc-card">
          <!-- 2-col current/parsed 內 inline render 8 fields (system_name / sensitivity / status / target_type / scope_desc / system_identifier / description) -->
          <!-- SelectButton bind store.decisions.system_characteristic.action -->
        </div>
        <div v-else class="empty-state">
          <i class="pi pi-check-circle empty-icon" />
          <p>受評標的無差異</p>
        </div>
      </DiffSectionShell>
    </template>

D.6 Commit Phase O-D

  • cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
    git add src/components/grc/ssp-docx-import-v2/diff/
    git commit -m "feat(ssp-oscal-alignment): Bug O Phase D — FE 4 個新 DiffSection + DiffCard 元件
    
    - SystemCharacteristicDiffSection (單筆) / ComponentsDiffSection / LeveragedAuthorizationsDiffSection / InventoryItemsDiffSection
    - 3 個 DiffCard mirror PartyDiffCard pattern (2-col current/parsed + SelectButton 三 action)
    - SC section 不用 DiffCard,section 內 inline render 單筆
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

§6

Phase O-E:DiffResolutionStep TabView refactor

Files:

  • Modify: ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/components/grc/ssp-docx-import-v2/diff/DiffResolutionStep.vue

E.0 Pre-flight

E.1 改寫 template

E.2 active tab logic

E.3 onNext + allSkip check 擴展

E.4 Visual smoke test(user 手動)

E.5 Commit Phase O-E

  • cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
    git add src/components/grc/ssp-docx-import-v2/diff/DiffResolutionStep.vue
    git commit -m "feat(ssp-oscal-alignment): Bug O Phase E — DiffResolutionStep TabView refactor
    
    - 把既有 2 section inline render 改成 6-tab TabView 容器
    - 每 tab header 顯示對應 changed/added/gone count badge
    - 無衝突 tab 隱藏(避免 user 困惑)
    - active tab default = 第一個有 diff 的 tab
    - allSkip check 擴展到 7 key 都算
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

§7

Phase O-F:i18n + 收尾

F.1 i18n

F.2 design.md §11.34 收尾

F.3 寫 changelog + FIXED-SUMMARY

F.4 跨 repo E2E verify(user 手動)

    • Step 0 upload / Step 1 預覽 → Step 2 解決差異(看到 6 tab + badge)
    • 對 SC / 1 component / 1 LA / 1 inventory 各設一個 keep_current
    • 對 1 component 設 use_docx
    • 對 1 inventory 設 skip(added)
    • 進 Step 3 確認
  • -- keep_current 的 row 應該 = current value,不被 docx 改
    -- use_docx 的 row 應該 = parsed value
    -- skip(added) 的 row 應該 不存在 DB

F.5 commit + 等 user push

  • git add docs/features/FR-028-2605-ssp-oscal-alignment/design.md docs/changelog/2026-05-25-feat-bug-o-diff-stepper-4key-tabview.md docs/features/FR-028-2605-ssp-oscal-alignment/handoff/2026-05-25-bug-o-FIXED-SUMMARY.md
    # FE i18n 在 FE repo commit
    cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
    git add src/locales/
    git commit -m "feat(ssp-oscal-alignment): Bug O Phase F — i18n 4 個新 tab 標題"

§8

Acceptance criteria

§9

不在 scope

  • preview UI(SystemCharacteristicSection / LeveragedSection 等)的 inline edit 路徑(已 Bug L-2 + M-B 解,跟 diff stepper 不同 phase)
  • Excel import diff path(未來再對齊)
  • template_module_frame_id entity field wire(Bug K1 follow-up)
  • jedi-* 進版 + BE/FE 版號對齊(user 拍板)
  • party 的 row_uid stable hash 改進(既有用 sha1(name|email) 已足)

§10

Pre-flight check 開工前一次性跑

預估時間:8-12 小時(含 test + commit + E2E verify)