# SSP 差異更新匯入復原 Implementation Plan（V1 體驗整套接回 v2）

> **狀態（2026-06-16）：7 phase 全部實作 + review（spec + code quality 兩段式）+ 單元/整合測試 + BOOT OK + baseline 零新回歸 完成。** 另含過夜抓到的兩個真 bug 修復（D1 docx source_type 契約、D3 by-component 敘述被丟）。⚠️ **real-DB HTTP smoke 待 user 驗收**（專案 266 / ssp 1482）—— 未標 FIXED / shipped。收尾文件見 `docs/changelog/2026-06-16-*`、SUMMARY（`handoff/2026-06-16-import-ssp-restore-SUMMARY.md`）、分析（`docs/analysis/2026-06-16-ssp-import-restore-w2-architecture-and-bugs.md`）。

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 把 FR-011.2 已出貨的「SSP 差異更新匯入」體驗（上傳 docx/excel → 逐項差異比對 → 接受/保留決策 → 人員自動比對 → 手動指派 → 寫入）整套接回 `jedi_oscal_v2`，驅動目標是讓**專案 SSP（piece 3）**能匯入更新；資源庫重複匯入是 by-product。

**Architecture:** 套件 `OscalIoService.import_ssp` 加 `mode='update'`（非空 SSP 逐子物件 upsert）+ `build_ssp_snapshot(ssp_id)`（current 端 OSCAL 快照）。主專案 confirm 流程改成 **parse → adapter(parsed→OSCAL dict) → diff(vs snapshot) → 逐項 decision → merge 成最終 OSCAL dict → `import_ssp(mode='update')`**。人員比對在 parse/preview 時對 parsed parties 跑三層配對並回填，confirm 時隨 merge dict 寫入。

**Tech Stack:** Python 3.11 / Flask-RESTful / SQLAlchemy / dependency-injector / pytest；套件 `jedi_oscal_v2`（dev 走 poetry path-dep，不 commit `pyproject.toml`）；DDD 分層（api/app/domain/infra）。

---

## ⚠️ 架構決策（必讀，與 handoff 字面有一處刻意分歧 — ✅ user 2026-06-15 拍板採 W2）

**決策 W：decision 在 OSCAL-dict 層 merge，單一寫入路徑 `import_ssp(mode='update')`（取代「復活 v1 write strategy」）**

- **handoff §B.3 字面**：把 `ssp_write_strategy` / `module_frame_write_strategy` 改走 v2 `SspService` 子物件 CRUD、依 decisions 逐子物件寫入（= 復活 v1 那 1000+ 行 strategy）。
- **本 plan 採 W2**：decisions 在「parsed OSCAL dict ↔ current snapshot dict」層 merge 成一份最終 OSCAL dict，再呼 `import_ssp(mode='update')` 一次寫入。
- **為何分歧**：handoff §A.1 本來就要 `import_ssp(update)` 做「逐子物件 upsert」。若再復活 write strategy 做第二套子物件寫入，等於**兩套寫入路徑**（套件 upsert + 主專案 CRUD），違反 design.md §3「避免兩套寫入」。W2 讓 `import_ssp(update)` 是唯一 writer，decision 退化成 dict 選擇/合併（純資料轉換、好測），write strategy 的角色被 merge 步驟取代。
- **W2 仍涵蓋 V1 全部能力**：per-control/per-AO 決策 = merge 時逐項選 parsed/current；manual_assignment = merge 前把未匹配段落內容注入 parsed dict 的目標控制項/AO；reconciliation = 在 parsed parties 上回填 match 結果，隨 dict 寫入。
- **回退條件**：若 party 的 matched_user/org 寫入、responsible-party link、或某子物件的「保留現值」語意在 dict 層無法乾淨表達（例如需要 partial-field 級保留），再退回 W1 對該子物件做 strategy CRUD。Phase 4/5 pre-flight 會驗。

> v1 `ssp_write_strategy` / `module_frame_write_strategy` / `i_ssp_docx_write_strategy` **不刪檔、停用**（design.md §8）；本 plan 不 wire 它們進 boot（仍 import v1 jedi_oscal entity，會撞 MetaData）。它們只當 diff/reconcile 邏輯的 port 參照。

---

## 📌 已驗證地基（2026-06-15 讀 live code，executor 零脈絡可直接信；欄位級仍以各 phase pre-flight 為準）

**套件 `jedi_oscal_v2`**（`jedi-oscal-v2/jedi_oscal_v2/`）
- `app/service/io/oscal_io_service.py`：
  - `import_ssp(oscal_ssp, *, target_ssp_id, curr_user, mode='create')` `:247-320`。`mode!='create'` raise `:278`；非空 guard（`_sys_char_repo`/`_sys_impl_repo`/`_control_impl_repo` 的 `get_by_ssp` 任一非 None）raise `:289-298`。
  - 寫入順序：`_import_system_implementation`（先，產 `comp_map: {component-uuid → new id}`）→ roles / parties / system-characteristics / control-implementation。各 `_import_*` **全 insert-only**。
  - `_import_*` 清單：`_import_roles :817`、`_import_parties :835`、`_import_system_characteristics :859`（含 information-types nested）、`_import_system_implementation :926`（含 components/leveraged/inventory nested）、`_import_control_implementation :988`（含 IR + statements + by-components nested）、`_import_by_component :1047`（dual-parent 擇一：`implemented_requirement_id` 或 `statement_id`）。
  - `__init__` 注入 20 repo（`_ssp_repo`/`_sys_char_repo`/`_sys_impl_repo`/`_component_repo`/`_control_impl_repo`/`_impl_req_repo`/`_statement_repo`/`_by_component_repo`/`_leveraged_repo`/`_inventory_repo`/`_info_type_repo`/`_metadata_repo`/`_role_repo`/`_party_repo`/`_resource_repo`/catalog 5 + ar/poam 6）。
  - export builders（snapshot 鏡像來源）：`_export_ssp` / `_build_metadata :323` / `_build_system_characteristics` / `_build_system_implementation` / `_build_control_implementation`。OSCAL key 為 kebab-case。
- `app/service/ssp/ssp_service.py`：**完整 CRUD**。1:1 子物件：`get_system_characteristics(ssp_id)` / `upsert_system_characteristics(sc)` `:135`、`get_or_create_system_implementation(ssp_id)` `:199`、`get_or_create_control_implementation(ssp_id)` `:212`。集合子物件每類 `add_/list_/get_/update_/delete_`：components / implemented_requirements / by_components / inventory_items / leveraged_authorizations / statements / parties。另 `list_*_for_ssp`（`list_components_for_ssp :226` / `list_inventory_items :241` / `list_leveraged_authorizations :258` / `list_implemented_requirements_for_ssp :279`）、`list_statements(ir_id) :299`、`list_parties(metadata_id) :328`、`deep_clone_ssp :347`。
- `app/service/ssp/ssp_clone_service.py`：寫入順序 + by-component `component_id` remap pattern（`component_id_map.get(bc.component_id, bc.component_id)`）= update-mode upsert 的寫入順序/remap 參照。
- 子物件自然 identity（upsert key）：IR=`(control_implementation_id, control_id)`；statement=`(implemented_requirement_id, statement_id)`；by-component=`(parent_id, component_id)`；party=`(metadata_id, type, name)`；component=`(system_implementation_id, title, type)`；role=`(metadata_id, role_id)`；sys_char/sys_impl/control_impl 為 1:1 by `ssp_id`。
- **`parties` 表（`infra/model/base/oscal_party.py:14`）無 `matched_user_id`/`matched_org_unit_id` 欄位** — 只有標準 OSCAL 欄 + `props`/`external_ids`/`links`（jsonb）。⚠️ reconciliation 結果在 v2 要存哪是 Phase 5 pre-flight 決策。
- 套件 import 測試：`tests/io_export/test_oscal_import.py`（`test_import_ssp_roundtrip` / `_nonempty_target_raises` / `_unknown_target_raises`）。

**主專案 V1 disabled（port 參照，import v1 entity，不可 wire）**
- `app/oscal/service/ssp_docx_diff_service.py`：`DiffStatus = Literal["unchanged","changed","added","gone"]`（**4 值** `:8`）套在 **7 個 section**（control 敘述 / per-AO 敘述 / parties / components / leveraged / inventory / system_characteristic）。`compute_default_action_for_text :31`、`compute_party_diff :149`、`compute_component_diff :433`、`compute_leveraged_diff :446`、`compute_inventory_diff :459`、`compute_sc_diff :477`、`build_diff_summary :675`、`annotate_parse_result :727`（current 端目前讀 v1 entity → dict，要改吃 snapshot dict）。smart default：`current 空+parsed 有`→added/None；`current 有+parsed 空`→gone/keep_current；相等→unchanged/None；不等→changed/keep_current。
- `domain/oscal/service/reconciliation/`：`base.py BaseReconciliationService`（三層 fallback `_try_user_selected_match`→`_try_exact_match`→`_try_normalized_match`→`_try_fuzzy_match`→`_apply_unmatched`）、`person_reconciler.py`（email exact/normalized + domain fuzzy）、`organization_reconciler.py`（name + suffix fuzzy）、`match_method.py MatchMethod`（USER_SELECTED/EXACT/NORMALIZED/FUZZY_EMAIL_DOMAIN/FUZZY_NAME_PREFIX/UNMATCHED）。orchestrator `domain/oscal/service/party_reconciliation_service.py reconcile(parties, tenant_id)` 原地回填 `matched_*_id`/`match_method`。其餘 reconciler：catalog_control / assessment_objective / leveraged / system_characteristic / ssp_entity_orchestrator。
- `domain/oscal/strategy/i_ssp_docx_write_strategy.py`：`ImportDecisions{decisions:list[dict], manual_assignments:list[dict], skipped_paragraph_idxs, predicted_controls_user_selection}`、`ImportResult{created,updated,skipped,manual_assigned,manual_skipped,missing_left_blank}`。`decisions[i] = {control_id, action:'use_docx'|'keep_current'|'skip', objectives:[{objective_key, action}]}`。

**主專案 V2 現行管線**
- adapter：`app/oscal/service/import_adapter/docx_to_oscal_ssp.py parsed_docx_to_oscal_ssp(parsed_result)->(oscal_dict, warnings)`、`excel_to_oscal_ssp.py parsed_excel_to_oscal_ssp(...)`、共用 `_common.py`（`soa_props(applicable, impl_status)`、`maybe_by_component(...)`、`assemble(...)`）。SoA props key：`applicability`(applicable/not-applicable)、`implementation-status`；by-component 狀態 `{"state": ...}`。`v2_candidate_loader.py load_candidates_from_catalog(catalog_id, *, control_repo, part_repo)`。
- `app/oscal/service/ssp_excel_import_app_service.py`：`confirm_import :231` 依 `job.source_type` 分派 `_confirm_create_resource_library`(framework_version) / `_confirm_update_module_frame`(module_frame)；`_fill_template_ssp(template_ssp_id, parsed_result, user_context) :343`（**寫死 `mode="create"`**，是注入更新模式的點）。`_VALID_SOURCE_TYPES={"framework_version","module_frame"}`。
- `app/oscal/service/ssp_docx_import_app_service.py`：`_VALID_SOURCE_TYPES={"module_frame"}` `:49`；`confirm_import :223`；parse 時對 parties 跑 `self._reconciliation.reconcile`（預覽級，不回填寫入）`:324-328`。
- `app/oscal/service/export/ssp_v2_content_loader.py SspV2ContentLoader.load(ssp_id, *, source_type, source_uid)`：已把整個 v2 SSP 讀成 `SspExportDataModel`（snapshot current 端可借此讀法或套件內 `_export_ssp` 反向）。
- route + 註冊：`config/di_modules.py:13 EXCLUDE_MODULES` 現僅含 `api.oscal.routes.ssp.ssp_scoped_excel_import_route`（專案 excel，dark）+ 框架/AP/AR/profile B 系列；docx/excel 資源庫 route 已 re-enabled。`api/oscal/routes/ssp/ssp_scoped_excel_import_route.py`（存在）force `source_type="ssp"`、source_uid 取自 URL、共用 `SspExcelImportAppService`。同步要在 `api/oscal` 的 `create_module()` 註冊 blueprint。
- parse-job：`domain/oscal/service/ssp_excel_parse_job_domain_service.py` / `ssp_docx_parse_job_domain_service.py`（`create/get_one/update_status/write_parsed_result/write_error/write_import_summary/deactivate`；`parsed_result` JSONB）。
- catalog title：`domain/oscal/repository/i_ssp_catalog_title_query.py`（`get_titles_by_ssp_id` / `get_ao_list_by_ssp_id` / `get_objective_titles_by_ssp_id`），impl `infra/oscal/repository/ssp_catalog_title_query.py`。
- DI：`di_containers/oscal/oscal_containers.py` 註冊 `ssp_excel_import_app_service`（`:339-355`）/ `ssp_docx_import_app_service`（`:357-387`）；`oscal_io_service` / `resource_library_app_service` / `framework_service` 為共用 primitive。

---

## 🗂️ File Structure（建立/修改）

**套件 `jedi_oscal_v2`（dev path-dep，整弧完才發 Nexus）**
- Modify `jedi_oscal_v2/app/service/io/oscal_io_service.py` — `import_ssp` 加 update mode + 子物件 upsert helper + `build_ssp_snapshot`
- Test `tests/io_export/test_oscal_import_update.py` — update mode upsert + round-trip（新檔）
- Test `tests/io_export/test_oscal_snapshot.py` — `build_ssp_snapshot` 鏡像 export（新檔）

**主專案 — diff / merge / reconcile（接 v2，新檔或改 disabled 檔的 v2 版）**
- Create `app/oscal/service/import_diff/ssp_diff_service.py` — port 自 `ssp_docx_diff_service.py`，current 端吃 snapshot dict（不 import v1 entity）
- Create `app/oscal/service/import_diff/decision_merge.py` — 依 `ImportDecisions` 把 parsed dict + snapshot dict merge 成最終 OSCAL dict
- Create `domain/oscal/reconciliation_v2/person_reconciler.py` / `organization_reconciler.py` / `base.py` / `match_method.py`（或就地把現有 `reconciliation/` 改吃 v2，視 Phase 5 pre-flight）
- Create `app/oscal/service/import_diff/party_reconciliation_v2_service.py` — orchestrator 吃 v2 party + 現行 user/org domain service

**主專案 — confirm 流程 + route（改）**
- Modify `app/oscal/service/ssp_excel_import_app_service.py` — `_VALID_SOURCE_TYPES` 加 `"ssp"`；source verify 加 ssp 分支；confirm 加 Model C；`_fill_template_ssp` 參數化 mode + 接 diff/merge
- Modify `app/oscal/service/ssp_docx_import_app_service.py` — 同上 docx 端 `"ssp"`
- Modify `config/di_modules.py` — 移除 `ssp_scoped_excel_import_route`
- Modify `api/oscal/__init__.py`（或 `create_module()` 所在）— 註冊 ssp_scoped blueprint
- Modify `di_containers/oscal/oscal_containers.py` — wire `ssp_service` / catalog_title_query / snapshot / diff / reconcile_v2 進 import app service
- 新增 error code：`common/code/grc_error_code.py`（grep 最大序號續編）

---

## Phase 1 — 套件 `import_ssp(mode='update')` 子物件 upsert〔A.1〕

> **Pre-flight（開工先驗，不憑摘要）**
> - [ ] 讀 `oscal_io_service.py:817-1078` 全部 `_import_*` 實體，確認各自寫哪個 repo、現行 insert 呼叫（`*_repo.add()`）。
> - [ ] 確認各 repo 是否有 by-natural-key 查詢（如 `_impl_req_repo.get_by_control(...)`）；若無，update mode 用 `list + 建 key map` 比對（mirror SspCloneService 的 list 寫法）。
> - [ ] 確認 1:1 子物件可用 `SspService.upsert_system_characteristics` / `get_or_create_system_implementation` / `get_or_create_control_implementation`，或 io_service 內直接 repo update。決定 io_service 走 repo 還是 delegate SspService（傾向 repo 一致，io_service 既有風格）。

**Files:**
- Modify: `jedi-oscal-v2/jedi_oscal_v2/app/service/io/oscal_io_service.py`
- Test: `jedi-oscal-v2/tests/io_export/test_oscal_import_update.py`

- [ ] **Step 1: 寫 failing test — update mode 對非空 SSP upsert（不重複、可改值）**

```python
# tests/io_export/test_oscal_import_update.py
def test_import_ssp_update_upserts_into_nonempty(db_session, seeded_ssp):
    svc = make_oscal_io_service(db_session)
    # 先 create 一份
    svc.import_ssp(SSP_DICT_V1, target_ssp_id=seeded_ssp.id, curr_user="t", mode="create")
    # 同 control_id 改敘述 + 新增一個 control → update mode 不該丟錯
    counts = svc.import_ssp(SSP_DICT_V2, target_ssp_id=seeded_ssp.id, curr_user="t", mode="update")
    irs = svc._impl_req_repo... # 查同一 control_id 只有一筆且敘述= V2
    assert <control AC-1 敘述 == V2 值 and 無重複 and 新 control 已新增>
```

- [ ] **Step 2: 跑測試確認 FAIL**（`mode!='create'` 目前 raise）

Run: `cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 && poetry run pytest tests/io_export/test_oscal_import_update.py -v`
Expected: FAIL `unsupported mode 'update'`

- [ ] **Step 3: 實作 update mode**

- `mode` 守門放寬至 `{'create','update'}`（其餘仍 raise）。
- guard：`mode=='create'` 才檢查空殼；`mode=='update'` 跳過。
- **預設傾向**：1:1 子物件 delegate `SspService.upsert_system_characteristics` / `get_or_create_system_implementation` / `get_or_create_control_implementation`，集合子物件用 `SspService` 的 `list_*` + `add_/update_`，避免在 `OscalIoService` 重造 upsert（`SspCloneService` 已有寫入順序 + by-component remap 可參照）。除非 pre-flight 發現 io_service 直接持 repo 更順手，否則走 SspService。
- 各 `_import_*` 抽出「寫一筆」邏輯，加 `mode` 參數：
  - 1:1（sys_char/sys_impl/control_impl）：`get_by_ssp` 有則 update（套既有欄位）、無則 add。
  - 集合（IR/statement/component/party/leveraged/inventory/by-component）：開工前 `list` 現有 → 建 `自然key → entity` map → 每筆 parsed 命中則 update、未命中則 add。by-component 沿用 `comp_map` remap。
- update mode 仍回 counts（可加 `updated`/`created` 細分）。

- [ ] **Step 4: 跑測試確認 PASS** — Run 同 Step 2，Expected: PASS

- [ ] **Step 5: 補 round-trip 回歸測試**（update 後 export ≈ 最終 dict）+ 跑既有 `test_oscal_import.py` 確認 create 行為不變

Run: `poetry run pytest tests/io_export/ -v`
Expected: 既有 create 測試全綠 + 新 update 測試綠

- [ ] **Step 6: Commit（套件 repo，顯式 add）**

```bash
cd ~/Projects/Jedicogy/module/jedi-python-package
git add jedi-oscal-v2/jedi_oscal_v2/app/service/io/oscal_io_service.py jedi-oscal-v2/tests/io_export/test_oscal_import_update.py
git commit -m "feat(oscal-v2): import_ssp update mode — 非空 SSP 逐子物件 upsert（FR-038 import-ssp P4-A1）"
```

---

## Phase 2 — 套件 `build_ssp_snapshot(ssp_id)`〔A.2〕

> **Pre-flight**
> - [ ] 讀 `_export_ssp` / `_build_*`，確認能用 `ssp_id`（非 uid）組出 body dict。snapshot 與 `import_ssp` 吃的 dict **同形狀**（kebab-case，`{"system-security-plan": {...}}` 或 body）。
> - [ ] 對照 Phase 1 的 merge 需求：snapshot 要含 control-implementation/IR/statement/parties/components/SC，欄位齊全才能 diff。

**Files:**
- Modify: `jedi-oscal-v2/jedi_oscal_v2/app/service/io/oscal_io_service.py`
- Test: `jedi-oscal-v2/tests/io_export/test_oscal_snapshot.py`

- [ ] **Step 1: 寫 failing test** — 對 seeded 非空 SSP `build_ssp_snapshot(id)` 回 dict，含 control-implementation + parties，且 `import_ssp(snapshot, mode='update')` 回原 SSP（snapshot↔import 對稱）。
- [ ] **Step 2: 跑確認 FAIL**（method 不存在）
- [ ] **Step 3: 實作** `build_ssp_snapshot(self, ssp_id) -> dict`：薄包 `_export_ssp`（用 ssp_id 取 ssp entity）回 body dict。
- [ ] **Step 4: 跑確認 PASS**
- [ ] **Step 5: Commit**

```bash
git add jedi-oscal-v2/jedi_oscal_v2/app/service/io/oscal_io_service.py jedi-oscal-v2/tests/io_export/test_oscal_snapshot.py
git commit -m "feat(oscal-v2): build_ssp_snapshot — current 端 OSCAL 快照（FR-038 import-ssp P4-A2）"
```

> **套件改完 → 主專案 BOOT 驗**：`pyproject.toml` 已是 path-dep，重啟即生效。跑 §6 BOOT 指令確認 `BOOT OK`。

---

## Phase 3 — 專案 SSP 可匯入（Model C，先直接 update，無 diff）= 首個出貨里程碑

> **本 phase 完 user 能看到**：專案 SSP（ssp 1482）上傳 excel/docx → 內容寫入更新（覆蓋式 upsert）。
> **本 phase user 還看不到**：逐項差異預覽 / 接受保留決策 / 人員自動比對 / 手動指派（Phase 4~6）。**這不是終點。**

> **Pre-flight**
> - [ ] 讀 `ssp_scoped_excel_import_route.py` 全文，確認 4 endpoint + force `source_type="ssp"` + source_uid 來自 URL。
> - [ ] 找 `create_module()` 在哪註冊 oscal blueprint（grep `create_module` under `api/oscal`），確認 re-enable 要加哪一行。
> - [ ] 釐清 ssp uid → ssp_id + 專案 living_ssp 解析路徑 + 權限（manager）：grep `living_ssp_id` / project resolver / `SspService.get_ssp`。
> - [ ] 確認 `import_ssp(mode='update')`（Phase 1）對專案 SSP 既有 body 能 upsert（real-DB smoke 前置）。

**Files:**
- Modify: `config/di_modules.py`（移除 EXCLUDE）、`api/oscal/__init__.py`（註冊 blueprint）
- Modify: `app/oscal/service/ssp_excel_import_app_service.py`（`"ssp"` source_type + Model C + `_fill_template_ssp` 加 `mode` 參數）
- Modify: `app/oscal/service/ssp_docx_import_app_service.py`（`"ssp"` source_type + Model C）
- Modify: `di_containers/oscal/oscal_containers.py`（wire `ssp_service` + project resolver 進 import app service）
- Modify: `common/code/grc_error_code.py`（新碼：`GRC_SSP_NOT_FOUND` 若無 / `GRC_IMPORT_SSP_NOT_PROJECT_MEMBER` 等，grep 最大序號續編）
- Test: `test/test_ssp_excel_import_project_ssp.py`（app service 測，logger patch fixture）

- [ ] **Step 1: 寫 failing app-service test** — `confirm_import` 對 `source_type='ssp'` 的 job → 呼 `import_ssp(mode='update')` 寫入既有專案 SSP（mock oscal_io，斷言 `mode='update'` + target = 解析出的 living_ssp_id）。

```python
# test/test_ssp_excel_import_project_ssp.py
@pytest.fixture(autouse=True)
def _patch_logger(monkeypatch):
    monkeypatch.setattr("app.oscal.service.ssp_excel_import_app_service.logger", logging.getLogger("test"))

def test_confirm_project_ssp_calls_update_mode(svc, ssp_job, mock_oscal_io):
    svc.confirm_import(ssp_job.uid, {}, user_context_manager)
    mock_oscal_io.import_ssp.assert_called_once()
    assert mock_oscal_io.import_ssp.call_args.kwargs["mode"] == "update"
    assert mock_oscal_io.import_ssp.call_args.kwargs["target_ssp_id"] == EXPECTED_LIVING_SSP_ID
```

- [ ] **Step 2: 跑確認 FAIL**（`source_type='ssp'` 目前 raise `GRC_EXCEL_SOURCE_TYPE_INVALID`）

Run: `pytest test/test_ssp_excel_import_project_ssp.py -v`

- [ ] **Step 3: 實作 Model C（excel）**
- `_VALID_SOURCE_TYPES` 加 `"ssp"`。
- source verify 加 `source_type=='ssp'` 分支：解析 ssp uid → ssp + 權限（manager）。
- `confirm_import` 加 `if job.source_type == "ssp": return self._confirm_update_project_ssp(...)`。
- `_confirm_update_project_ssp`：解析 living_ssp_id → `_fill_template_ssp(ssp_id, parsed_result, user_context, mode="update")`。
- `_fill_template_ssp` 加 `mode="create"` 參數（預設不變，向後相容），把寫死的 `mode="create"` 改用參數。

- [ ] **Step 4: 跑確認 PASS**

- [ ] **Step 5: docx 端同步加 `"ssp"` source_type + Model C**（mirror excel；docx app service test 一支）

- [ ] **Step 6: re-enable route + 註冊 blueprint + wire DI**
- `config/di_modules.py`：移除 `"api.oscal.routes.ssp.ssp_scoped_excel_import_route"`。
- `api/oscal/__init__.py`（或 create_module 所在）：註冊 ssp_scoped(excel) blueprint。
- **docx 端 'ssp' 路徑（reviewer flagged，先釐清再實作）**：`ssp_scoped_excel_import_route.py` 只覆蓋 **excel** scoped 路徑；docx **無**對應 scoped route 檔。pre-flight 先確認 docx-into-project-SSP 是「沿用既有 docx import route + payload 帶 source_uid + `'ssp'` source_type 分支」還是「需新建 docx scoped route」。**兩種都要 wire 到，否則 docx 落專案 SSP 只接一半。**
- `di_containers/oscal/oscal_containers.py`：import app service 注入 `ssp_service` + project resolver（source verify 用）。

- [ ] **Step 7: BOOT 驗 + baseline 零回歸**

Run（§6 BOOT 指令）→ Expected `BOOT OK`
Run: `poetry run python -m pytest test/ -q -p no:cacheprovider --continue-on-collection-errors 2>&1 | grep -E '^(FAILED|ERROR)' | sort > /tmp/p3.txt; comm -13 /tmp/ssprestore_base.txt /tmp/p3.txt`
Expected: 空輸出（無新增 FAILED/ERROR）

- [ ] **Step 8: real-DB smoke（專案 266 / living_ssp 1482）** — user 重啟 BE 後，前端或 curl 上傳專案 SSP excel → 確認 ssp 1482 子物件更新（SELECT 對照）。**此步需 user 起服務**，列入手測 checklist。

- [ ] **Step 9: Commit（主專案，顯式 add 各檔）**

```bash
git add config/di_modules.py api/oscal/__init__.py app/oscal/service/ssp_excel_import_app_service.py app/oscal/service/ssp_docx_import_app_service.py di_containers/oscal/oscal_containers.py common/code/grc_error_code.py test/test_ssp_excel_import_project_ssp.py
git commit -m "feat(FR-038): 專案 SSP 檔案匯入（Model C，import_ssp update mode）+ re-enable ssp_scoped route"
```

---

## Phase 4 — diff 標注 + decision merge（接 v2 snapshot）〔B.1 + B.4〕

> **本 phase 完 user 能看到**：重複/專案匯入時逐項差異（unchanged/changed/added/gone）+ smart default + 接受/保留逐項生效。

> **Pre-flight**
> - [ ] 讀 `ssp_docx_diff_service.py` 全 860 行，確認 7 section 的 compute_* + annotate 的輸入/輸出 dict 形狀。
> - [ ] 確認 parsed adapter dict 與 `build_ssp_snapshot` dict 的欄位能對齊比對（control-id / statement-id / party name+type 為 join key）。
> - [ ] 驗 W2 merge 可表達「per-AO keep_current」：snapshot 提供 current 值、parsed 提供 new 值、decision 選邊 → 寫進 merge dict。

**Files:**
- Create: `app/oscal/service/import_diff/ssp_diff_service.py`（port，current 端吃 snapshot dict）
- Create: `app/oscal/service/import_diff/decision_merge.py`
- Modify: excel/docx import app service — confirm 改 `parse → diff(vs snapshot) → decisions → merge → import_ssp(update)`；preview（get_parse_result）回 annotated diff
- Test: `test/test_ssp_diff_service.py` / `test/test_decision_merge.py`

- [ ] **Step 1: 寫 failing test — diff 4 status × 多 section**（port v1 diff 測試資料，current 端改餵 snapshot dict）
- [ ] **Step 2: 跑確認 FAIL**
- [ ] **Step 3: 實作 `SspDiffService.annotate(parsed_oscal_dict, snapshot_oscal_dict) -> annotated`**（搬 compute_default_action_for_text + 7 section diff + build_diff_summary，全部吃 dict、零 v1 entity import）
- [ ] **Step 4: 跑確認 PASS**
- [ ] **Step 5: 寫 failing test — decision_merge**（給 parsed + snapshot + ImportDecisions → 最終 dict 逐項選對邊）
- [ ] **Step 6: 實作 `merge_by_decisions(parsed_dict, snapshot_dict, decisions) -> final_dict`**
- [ ] **Step 7: confirm 接上**：`get_parse_result` 回 annotated（diff preview）；`confirm_import` 走 `merge → import_ssp(mode='update')`
- [ ] **Step 8: BOOT + baseline 零回歸 + real-DB smoke（重複匯入資源庫 + 專案 1482 看 diff 生效）**
- [ ] **Step 9: Commit**

```bash
git add app/oscal/service/import_diff/ app/oscal/service/ssp_excel_import_app_service.py app/oscal/service/ssp_docx_import_app_service.py test/test_ssp_diff_service.py test/test_decision_merge.py
git commit -m "feat(FR-038): SSP 匯入差異標注 + 逐項決策 merge（接 v2 snapshot，import_ssp update）"
```

---

## Phase 5 — 人員/組織三層比對接 v2〔B.2〕

> **本 phase 完 user 能看到**：匯入的人員/單位自動配到系統 user/org，preview 顯示 match 結果 + method。

> **Pre-flight（最高槓桿 — 決定 match 結果存哪）**
> - [ ] **驗 v2 party 沒有 `matched_user_id`/`matched_org_unit_id` 欄位**（已確認）→ 決策：存 party `props`（如 `matched-user-id` / `matched-org-unit-id`）還是主專案 mapping 表？查 CLAUDE.md「OSCAL Parties」段 + grep `_enrich_party_user_org_names` 現有 enrich pattern。**此決策影響 adapter 怎麼把 match 帶進 OSCAL dict + import_ssp 怎麼寫。**
> - [ ] 確認現行 `user_domain_service` / `org_unit_domain_service` 的 query signature（reconciler 要改吃這兩個）。
> - [ ] 確認 parse 時 `self._reconciliation.reconcile`（docx app service `:324`）現在跑哪個 reconciler，是否已是 v2-safe 還是 import v1。

**Files:**
- Create/Modify: `domain/oscal/reconciliation_v2/`（person/org/base/match_method）或就地改 `reconciliation/` 吃 v2
- Create: `app/oscal/service/import_diff/party_reconciliation_v2_service.py`
- Modify: import app service — parse/preview 時回填 match 到 parsed parties；adapter 把 match 帶進 OSCAL dict（依 pre-flight 決策）
- Test: `test/test_party_reconciliation_v2.py`

- [ ] **Step 1: 寫 failing test — 三層配對**（exact email / normalized / fuzzy domain；org name + suffix fuzzy；user_selected label）
- [ ] **Step 2: 跑確認 FAIL**
- [ ] **Step 3: 實作 reconciler 接 v2**（吃現行 user/org domain service，回填 match + method + confidence；MatchMethod 沿用）
- [ ] **Step 4: 跑確認 PASS**
- [ ] **Step 5: 接進 parse/preview + 寫入 match 結果** — 寫入目標（party `props` / 主專案 mapping 表）+ 是否能隨 merge dict 走 `import_ssp`，**由本 phase pre-flight 決定後才實作，勿預設**。v1 是寫 `PartyEntity.user_id`/`org_unit_id` 欄位（`ssp_write_strategy.py:404-419`），v2 無此欄 → 這是 W2「單一 dict 寫入」唯一可能不成立、需回退 W1 的點。
- [ ] **Step 6: BOOT + baseline 零回歸 + real-DB smoke（看 preview 帶 matched user）**
- [ ] **Step 7: Commit**

---

## Phase 6 — 未匹配段落手動指派〔B.3〕

> **本 phase 完 user 能看到**：docx 解析出的未匹配段落，confirm 時可手動指派到控制項 / AO，內容寫入。

> **Pre-flight**
> - [ ] 讀 v1 `ssp_write_strategy._apply_manual_assignment`（`:179-225`）的 manual_assignment 結構 + 注入語意。
> - [ ] 確認 parsed dict 有 `unmatched_paragraphs`（docx parser 輸出）。

**Files:**
- Modify: `app/oscal/service/import_diff/decision_merge.py`（merge 前把 manual_assignments 注入 parsed dict 目標控制項/AO）
- Modify: docx import app service confirm（接 payload.manual_assignments）
- Test: `test/test_manual_assignment_merge.py`

- [ ] Step 1~5：failing test（手動指派注入）→ 實作 → PASS → confirm 接上 → BOOT + baseline + smoke → Commit

---

## Phase 7 — 兩落點端到端 + 收口驗收〔B.5〕

> **本 phase 完 = 本棒終點**：專案 SSP + 資源庫重複匯入兩落點，都走 parse→diff→per-control/per-AO decision→reconcile→manual assign→update，端到端通。

> **Pre-flight**
> - [ ] 對照 design.md §4 P4 驗收 + handoff WHY 終點清單，逐條確認。

- [ ] **Step 1: e2e smoke（測試專案 compliance-manager-test）** — 上傳真 docx/excel：(a) 資源庫重複匯入看 diff+decision；(b) 專案 ssp 1482 看 diff+decision+reconcile+manual。
- [ ] **Step 2: 跑全 baseline 零回歸** — `comm -13 /tmp/ssprestore_base.txt <(全測 FAILED/ERROR)` 空。
- [ ] **Step 3: 對照 handoff WHY 終點清單逐條打勾**（上傳→逐項差異→決定→人員配對→手動指派→寫入）。
- [ ] **Step 4: Commit + 列手測 checklist 給 user**（不自動收尾 / 不 push / 不發 Nexus — 等 user 明示）

---

## 🔑 開放決策 / 風險（開工途中遇到回報 user，不自決）

1. **W2 vs W1**（架構決策段）— 本 plan 採 W2，待 user/reviewer 確認；Phase 4/5 pre-flight 若發現 dict 層無法乾淨表達 partial 保留，回退該子物件走 W1。
2. **match 結果存哪**（Phase 5 pre-flight）— v2 party 無 matched_* 欄位；存 props 還是主專案 mapping 表，影響 adapter + import_ssp。
3. **人員比對是否保留三層 fuzzy**（handoff §10）— 本 plan 預設保留三層；若 user 要簡化，砍 `_try_fuzzy_match`。
4. **FR 編號**（handoff §10）— 暫掛 FR-038 import-ssp 線；若 user 要另開新 FR，rename 資料夾 + README 登記。
5. **套件發版時機** — 整弧完 + user 明示才 bump + 推 Nexus；dev 全程 path-dep，`pyproject.toml` 不 commit。

## 🧪 Test Strategy
- 套件（Phase 1/2）：`tests/io_export/` 單元，update upsert + snapshot 對稱 + create 回歸不變。
- 主專案（Phase 3~6）：app service / diff / merge / reconcile 單元，**每個 service test 加 logger patch autouse fixture**（memory `feedback_test_logger_patch_db_handler`，抄 `test_ssp_excel_import_app_service.py`）。
- 零回歸基準：每 phase `comm -13 /tmp/ssprestore_base.txt <(本次 FAILED/ERROR)` 必空（baseline 47f/50e=97 行）。**多日實作前先確認 `/tmp/ssprestore_base.txt` 還在**（`/tmp` 被清過要重跑 §6 baseline 指令重建，否則 `comm` 會吐整串 failure 看似大回歸）。
- e2e（Phase 7）：在 compliance-manager-test，真檔上傳兩落點。
- 每 phase 收尾：BOOT OK + real-DB smoke（266 / 1482）+ baseline 零回歸 + 顯式 git add commit。
