| 項目 | 內容 |
|---|---|
| 收尾日期 | 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) |
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 困擾「為什麼還有衝突」 |
| 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 乾淨。
下列檔案累計本 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.pydomain/oscal/strategy/ssp_write_strategy.pypyproject.tomltests/test_module_frame_write_strategy_v2_parties.pyRoot cause:app/oscal/service/party_context_service.py:update_party() 改 role 時呼叫 _link_party() 只 add 新 role link,沒 delete 舊 role link → oscal_responsible_parties 表內同 party_uuid 留兩筆 link(舊 role + 新 role)→ list_parties() 把每筆 rps 都展開 → DataTable 顯示同名兩筆。
Fix:update_party 改 role 時,先查同 (party_uuid, context_type, context_id) 下所有 link,砍掉 role 不一致的,再 add 新 role。
Verify:✓ user 第一輪測試後回報「1 好了」。
Root cause:_dict_to_parsed_parties line 1462-1468 對 docx 沒解出 role 的 party,hardcoded fallback:
"responsible-organization""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。
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。
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 — 矛盾指令)Root cause:commit 0c8fe93 feat(ssp-update-diff): PartiesSection respects parties_decisions 加 isPartyReadonly() — 對 keep_current action 的 party,整個 row 含 Dropdown / 5 個 InputText / link button 全鎖 readonly → user 在 Step 3 完全無法編輯。
Fix(FE sections/PartiesSection.vue):
:readonly="isPartyReadonly(p.party_uid)"<span> 唯讀 label)onFieldEdit 改值時自動把該 party action 從 keep_current 切到 use_docx(user 改了就表示要採用 + 修改)Symptom:confirm 後 DB ssp_components.leveraged_authorization_uid 全 NULL,雖然 LA 確實寫進 DB(ssp_leveraged_authorizations 有 row)且 title 跟 component.leveraged_authorization_ref hex 完全一致。
Root cause 推測:ComponentWriteStrategy.write 走 self._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 mapComponentWriteStrategy.write 加 optional la_title_to_uid 參數;caller 傳就用、不傳走 DB get_all(backward compat for legacy callers)confirm_service.confirm LA write 後 unpack tuple,把 map 餵 Component write3 個 test 同步更新(test_ssp_write_strategy.py 2 個 + test_ssp_import_confirm_service.py 1 個)。
Root cause:store _defaultPartyAction(party) / _defaultV3RowAction(row) 對 diff_status 為 changed 或 unchanged 的 row 預設 'skip'(「防誤動」設計)→ Step 3 PartiesSection 用 isPartySkipped(uid) filter 全濾掉 → 顯示「未識別出單位 / 未識別出人員」。
Fix(FE stores/sspDocxImportStore.js):
_defaultPartyAction / _defaultV3RowAction 對 changed / unchanged 改返 'keep_current'(不 'skip')效果:
_apply_v3_decisions changed × keep_current → inject current_values → 不覆寫 DB 既有資料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)但「自動 backfill」要真實寫入,所以加 _apply_v3_decisions 內 inner helper _merge_backfill(cur, par):對 cur 內空欄位用 par 補。unchanged path 跟 changed × keep_current/skip path 都走 backfill。
效果:
過程:我一開始把 _dict_to_parsed_system_characteristic 加 status=... 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 保留。
對既有 mf 重 docx import → Step 2 解決差異 tab:受評標的 / 系統元件 / 外部利用服務 顯示 DB 現值,docx 沒給的欄位不再標假衝突;Step 3 預覽頁所有 5 parties 顯示且可編輯角色 / 名稱;confirm 後 component 自動 link 到對應 LA、空欄位繼承現值不被空蓋掉、added rows 預設寫入。
| 段號 | 主題 |
|---|---|
| §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 |
| 檔案 | 教訓 |
|---|---|
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 找不到不會問問題只會抱怨 |
| 範圍 | 內容 |
|---|---|
| BE compliance-manager-be | 10+ 檔案改動,全 working tree 未 commit |
| FE compliance-manager-fe | 2 commits 已 push(adf1000 + 045b542) |
| jedi-* 套件 | 無改動 |
| DB migration | 無需(純行為改動,schema 不動) |
| RLS / 權限 | 無影響 |
| i18n | 無新 key |
| 項目 | 動作 |
|---|---|
| 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 補資料 |
| 項目 | 狀態 |
|---|---|
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」緩解 |
等 user 拍板: