SSP Docx Import Bug Arc (5/25 - 5/26) FIXED SUMMARY

項目 內容
收尾日期 2026-05-26
Task arc 跨度 2026-05-25 ~ 2026-05-26(2 天)
Branch feature/ssp-oscal-alignment
涵蓋 bug 數 8 個(4 個 user 主觀報的 + 4 個 fix 過程衍生的 regression / sub-bug)
FE 狀態 已 commit + push(adf1000, 045b542
BE 狀態 改動全 working tree 未 commit(等 user 拍板)
跨 repo BE + FE,無 jedi-* 套件改動
對話紀錄 docs/conversation-history/2026-05-25/ssp-docx-import-bug-arc/(8 part)+ docs/conversation-history/2026-05-26/ssp-docx-import-bug-arc/(2 part)

1. 緣由

User 反映 docx 重新匯入 module-frame/344ac369-916a-4e34-97ee-4d32ddc21cf3/template-edit(mf 379 / template SSP 278)後出現 4 大問題:

Bug User 描述
Bug 1 template-edit 參與人員 tab 編輯後存檔 → 多新增一筆同名 row
Bug 2 人員角色允許空但被預設成 system-user
Bug 3 Step 2 解決差異比對「現值 (DB)」對受評標的 / 系統依賴元件 / 外部利用服務都顯示空白
Bug 4 系統依賴元件 / 外部利用服務 confirm 後資料沒寫進 DB

過程中發現 / 衍生 4 個 sub-bug:

Sub-bug 描述
Step 3 parties readonly regression 之前 commit 0c8fe93 加的 isPartyReadonly lock 把 keep_current parties 全鎖死無法編輯
Component LA link bug confirm 後 ssp_components.leveraged_authorization_uid 全 NULL,LA 寫對但 component 沒 link 上
Step 3 parties 顯示 0 / 0 store _defaultPartyAction 對 changed/unchanged 預設 'skip' → PartiesSection isPartySkipped filter 全濾掉
Diff 永遠標 changed docx 沒解出某欄位但 DB 有值 → diff 算 changed → user 困擾「為什麼還有衝突」

2. Commits 清單

FE(user 自行 commit + push 的)

Hash 涵蓋
045b542 fix(ssp-docx-import): Step 3 party UX regression + 預設 action + org_unit filter
adf1000 fix(ssp-import): docx + excel cache invalidate + Step 2/3 UX 收斂

✓ FE 兩 commit 已 push 到 origin/feature/ssp-oscal-alignment,working tree 乾淨。

BE(全 working tree 未 commit

下列檔案累計本 arc 8 個 fix 的所有改動,等 user 拍板拆 commit 或一次包

檔案 涵蓋 bug
app/oscal/service/party_context_service.py Bug 1
app/oscal/service/ssp_docx_diff_service.py diff symmetric fix
app/oscal/service/ssp_docx_import_app_service.py Bug 2 / Bug 3 / Bug 4 / per-field merge / SC fallback revert
domain/oscal/import_pipeline/confirm_service.py Component LA link
domain/oscal/service/write_strategy/component_write_strategy.py Component LA link
domain/oscal/service/write_strategy/leveraged_write_strategy.py Component LA link
tests/test_party_context_service.py Bug 1 test
tests/test_ssp_docx_import_app_service.py Bug 4 test
tests/test_ssp_import_confirm_service.py confirm_service test 對齊 tuple
tests/test_ssp_write_strategy.py leveraged write test 對齊 tuple

另有先前 P/Q/R arc 留下的 working tree(未在本 arc 動):

  • domain/oscal/strategy/module_frame_write_strategy.py
  • domain/oscal/strategy/ssp_write_strategy.py
  • pyproject.toml
  • tests/test_module_frame_write_strategy_v2_parties.py
  • 多份 P/Q/R 相關 changelog / handoff / implementation-plan 文件(已存在 docs/)

3. 每個 Bug 詳細 root cause + fix

Bug 1:template-edit parties 編輯變新增 ✓ FIXED

Root causeapp/oscal/service/party_context_service.py:update_party() 改 role 時呼叫 _link_party() 只 add 新 role link,沒 delete 舊 role linkoscal_responsible_parties 表內同 party_uuid 留兩筆 link(舊 role + 新 role)→ list_parties() 把每筆 rps 都展開 → DataTable 顯示同名兩筆。

Fixupdate_party 改 role 時,先查同 (party_uuid, context_type, context_id) 下所有 link,砍掉 role 不一致的,再 add 新 role。

Verify:✓ user 第一輪測試後回報「1 好了」。


Bug 2:人員角色預設 system-user ✓ FIXED

Root cause_dict_to_parsed_parties line 1462-1468 對 docx 沒解出 role 的 party,hardcoded fallback:

  • organization → "responsible-organization"
  • person → "system-user"

Fix:拿掉 fallback,role=p.get("role") or "" — docx 沒給就保持空。寫入路徑 _upsert_responsible_party 已 normalize None → '' 並接受空 role 寫 link(schema role_id VARCHAR NOT NULL'' 合法)。

未動party_context_service._validate_payload 仍擋 manual CRUD dialog 空 role(template-edit 手動編輯人員 dialog)— 本期不在 scope。


Bug 3:Step 2 現值空白 ✓ FIXED

Root cause_load_current_v3_lists line 1608-1609 對 source_type == "module_frame"return empty — 註解寫「no template SSP yet → all-empty」假設首次匯入,但對「重新匯入既有 mf」情境(template SSP 已存在)錯誤 → diff 比對沒 current_values → FE 顯示「現值」空白。

Fix:module_frame 分支改走 _ssp_shell_service.resolve_existing_shell(source_uid) 取既有 SSP id(read-only,不 materialize)→ 找到走 project_ssp 同路徑 query 4 v3 lists;找不到(首次匯入)→ catch PreconditionFailedError → return empty。


Bug 4:系統依賴元件無法寫入 ✓ FIXED

Root cause_apply_v3_decisions line 1197-1203 對 diff_status="added" 且無 user decision 的 row → drop(防呆 default)。FE 沒送 components_decisions / leveraged_authorizations_decisions 時,所有 added rows 全 silent drop → 對重新匯入的 mf,components / LA / inventory 全沒寫進 DB。

Fix:added × no decision 改 inject parsed_values(mirror parties default + SC default 行為,符合 user「從無到有 docx 帶來就該寫入」直覺)。

例外

  • action="skip" → drop(user 明確跳過)
  • action="keep_current" → drop + log warning(added 表 DB 沒值,無從 keep — 矛盾指令)

Sub-bug A:Step 3 PartiesSection readonly regression ✓ FIXED(FE)

Root cause:commit 0c8fe93 feat(ssp-update-diff): PartiesSection respects parties_decisionsisPartyReadonly() — 對 keep_current action 的 party,整個 row 含 Dropdown / 5 個 InputText / link button 全鎖 readonly → user 在 Step 3 完全無法編輯。

Fix(FE sections/PartiesSection.vue):

  • 拿掉 9 處 :readonly="isPartyReadonly(p.party_uid)"
  • role Dropdown 永遠顯示(不再被換成 <span> 唯讀 label)
  • onFieldEdit 改值時自動把該 party action 從 keep_current 切到 use_docx(user 改了就表示要採用 + 修改)
  • 「保留現值」Tag 仍保留作為提示

Symptom:confirm 後 DB ssp_components.leveraged_authorization_uid 全 NULL,雖然 LA 確實寫進 DB(ssp_leveraged_authorizations 有 row)且 title 跟 component.leveraged_authorization_ref hex 完全一致。

Root cause 推測ComponentWriteStrategy.writeself._la.get_all(...) 查剛被 LeveragedWriteStrategy.write 寫的 LA — 雖然 base repo add() 已 flush,但 jedi-oscal / SQLAlchemy session 配置下同 transaction 內後續 get_all 在某些路徑下拿不到剛 add 的 row → la_title_to_uid 空 → 對不上 → 寫 NULL。

Fix:改 architecture 跳過 DB query:

  • LeveragedWriteStrategy.write signature 改返 tuple[int, dict[str, uuid.UUID]] — 第二個元素是剛 add 的 LA title → uid map
  • ComponentWriteStrategy.write 加 optional la_title_to_uid 參數;caller 傳就用、不傳走 DB get_all(backward compat for legacy callers)
  • confirm_service.confirm LA write 後 unpack tuple,把 map 餵 Component write

3 個 test 同步更新(test_ssp_write_strategy.py 2 個 + test_ssp_import_confirm_service.py 1 個)。


Sub-bug C:Step 3 parties 顯示 0 / 0 ✓ FIXED(FE)

Root cause:store _defaultPartyAction(party) / _defaultV3RowAction(row)diff_statuschangedunchanged 的 row 預設 'skip'(「防誤動」設計)→ Step 3 PartiesSectionisPartySkipped(uid) filter 全濾掉 → 顯示「未識別出單位 / 未識別出人員」。

Fix(FE stores/sspDocxImportStore.js):

  • _defaultPartyAction / _defaultV3RowAction 對 changed / unchanged 改返 'keep_current'(不 'skip')
  • added 仍 use_docx、gone 仍 keep_current(不變)

效果:

  1. Step 3 PartiesSection 顯示所有 parties 給 user 編輯
  2. BE _apply_v3_decisions changed × keep_current → inject current_values → 不覆寫 DB 既有資料
  3. Step 2 SelectButton 預設從「跳過」變「保留現值」(防誤動精神維持)

Sub-bug D:Diff 永遠標 changed(user 困擾「為什麼還有衝突」)✓ FIXED

Symptom:image #6 SC status 現值=active / 新值=空 標 changed;image #9 component leveraged_authorization_ref 現值=空 / 新值=有 標 changed。User:「都不該算差異 / 一個是 docx 沒給該繼承、一個是自動補資料」。

Root cause_compare_scalar_fields (v3) 跟 compute_party_diff 內 loop 對任何欄位不相等都標 changed — 沒考量「一方空」case。

Fix:兩處比對邏輯都加「任一方空 → 不算差異」(對稱 symmetric):

  • par 空 + cur 有 → 不算差異(docx 沒給 = 不修改)
  • cur 空 + par 有 → 不算差異(DB 沒值 + docx 補資料 = 自動 backfill)
  • 兩邊都有不同 → 真實差異
  • 兩邊都同 → equal

但「自動 backfill」要真實寫入,所以加 _apply_v3_decisions 內 inner helper _merge_backfill(cur, par):對 cur 內空欄位用 par 補。unchanged path 跟 changed × keep_current/skip path 都走 backfill。

效果:

  • UI 不再標假衝突
  • DB 自動 backfill 空欄位(典型場景:component.leveraged_authorization_uid NULL → docx 帶 link → 自動補上)

Sub-bug E:SC status default 'active' (revert) ✓ DONE

過程:我一開始把 _dict_to_parsed_system_characteristicstatus=... or "active" fallback 滿足 user「狀態為空時要有預設」要求。但 user 澄清:「word 沒給就繼承現值」不是「fallback 預設值」。

Fix:revert or "active",由 FE _defaultV3RowAction 預設 keep_current + BE _apply_v3_decisions SC keep_current 把 parsed_result.system_characteristic = None 略過 SC write → DB 既有 active 保留。


4. 行為差異一句話

對既有 mf 重 docx import → Step 2 解決差異 tab:受評標的 / 系統元件 / 外部利用服務 顯示 DB 現值,docx 沒給的欄位不再標假衝突;Step 3 預覽頁所有 5 parties 顯示且可編輯角色 / 名稱;confirm 後 component 自動 link 到對應 LA、空欄位繼承現值不被空蓋掉、added rows 預設寫入。


5. 規範文件齊全度


6. 待補 design.md §11 段(建議)

段號 主題
§11.39 Bug 1 — party_context_service update_party 改 role 砍舊 link
§11.40 Bug 2 — _dict_to_parsed_parties 拿掉 role default fallback
§11.41 Bug 3 — _load_current_v3_lists module_frame 走 resolve_existing_shell
§11.42 Bug 4 — _apply_v3_decisions added 預設 inject + 矛盾指令 drop
§11.43 Sub-bug A — PartiesSection readonly regression(FE)
§11.44 Sub-bug B — Component LA link bug(write strategy tuple 重構)
§11.45 Sub-bug C — store default keep_current(FE 防誤動但不 hide row)
§11.46 Sub-bug D — diff symmetric「任一方空算 unchanged」+ per-field merge backfill

7. 待補 memory feedback 教訓(建議)

檔案 教訓
feedback_session_visibility_after_repo_add.md base repo add() flush 但同 transaction 內 get_all 不保證拿到 — caller-provided map 比 query 安全
feedback_diff_emptiness_asymmetry.md docx vs DB 比對「一方空」case 通常該算「繼承」不該標 changed — 否則 user 困擾「為什麼還有衝突」
feedback_overwrite_semantic_needs_backfill.md strategy「delete all + insert from parsed」semantic 下 inject path 必須 per-field merge backfill,否則 docx 空欄位會把 DB 現值蓋掉
feedback_readonly_lock_hides_data.md UI filter / readonly 的「防誤動」設計要避免「資料消失看不到」變成 anti-pattern — user 找不到不會問問題只會抱怨

8. 跨 repo / 套件 / DB 影響

範圍 內容
BE compliance-manager-be 10+ 檔案改動,全 working tree 未 commit
FE compliance-manager-fe 2 commits 已 push(adf1000 + 045b542)
jedi-* 套件 無改動
DB migration 無需(純行為改動,schema 不動)
RLS / 權限 無影響
i18n 無新 key

9. 部署 handover(待 push 後)

項目 動作
BE 重啟 需要(service 層改動)
FE rebuild 已 push 過,prod 部署照常 build
DB migration
jedi- 進版*
既有資料修復 DB 內已存 ssp_components.leveraged_authorization_uid IS NULL 的歷史資料:下次 user 在 step 2 選「採用新值」對 components tab 即會自動 backfill(per _merge_backfill 邏輯);不需手動 SQL 補資料

10. 已知 follow-up

項目 狀態
party_context_service._validate_payload 仍擋 manual CRUD dialog 空 role 待用戶決定要不要也拿掉(template-edit 手動編輯人員 dialog 行為,本 arc 不在 scope)
jedi-oscal.role_id schema 仍 NOT NULL 寫入接受 '' 但 schema 沒鬆綁;未來若想真實允許 NULL 需 jedi-oscal 進版 + DB migration
4 個 v3 diff card 純唯讀(user 之前提的「step 3 解決差異也想能編輯」) 未動 — 純設計取捨,已透過「default keep_current + per-field merge」緩解

11. 收尾流程拍板事項

等 user 拍板:

  1. BE 改動是否要 commit(建議拆 4 個 commit:Bug 1 / Bug 2 / Bug 3+4 / sub-bug B+D;或一次包成 1 個 fix arc commit)
  2. 是否要補 design.md §11.39-46 段
  3. 是否要寫 changelog
  4. 是否要寫 4 條 memory feedback
  5. 是否要 push(per CLAUDE.md「push 永遠要 user 明確指示」)