# 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 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 好了」。

---

### 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_decisions` 加 `isPartyReadonly()` — 對 `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 仍保留作為提示

---

### Sub-bug B：Component LA link bug ✓ FIXED

**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 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_status` 為 `changed` 或 `unchanged` 的 row 預設 `'skip'`（「防誤動」設計）→ Step 3 `PartiesSection` 用 `isPartySkipped(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_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 保留。

---

## 4. 行為差異一句話

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

---

## 5. 規範文件齊全度

- [x] 對話紀錄歸檔 → `docs/conversation-history/2026-05-25/ssp-docx-import-bug-arc/`（8 sessions）+ `docs/conversation-history/2026-05-26/ssp-docx-import-bug-arc/`（2 sessions）
- [x] SUMMARY 報告（本檔）
- [ ] **design.md §11.X 新段**（待 user 下令後補）
- [ ] **changelog**（待 user 下令後補）
- [ ] **memory feedback 新教訓**（待 user 下令後補）
- [ ] **BE commits**（**全 working tree 未 commit**，等 user 拍板）

---

## 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 明確指示」）
