# Phase A3 Implementation Plan — 共用 matcher 抽取（parties / org-units）

> **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:** 把 docx + Excel 兩條 SSP 匯入路徑共用的 ParsedParty → User / OrgUnit reconciliation 邏輯抽成 `BaseReconciliationService` + Person / Organization Reconciler；引入 fuzzy 中間狀態（matched / fuzzy / unmatched），補上 A2 confirm flow 對 reconcile 的呼叫，並落實 cucumber regression 守 docx path 行為 100% 不變。

**Architecture:** Strategy Pattern + Generic Base — `BaseReconciliationService[TParsed, TEntity]` 在 `domain/oscal/service/reconciliation/` 提供 5 個 abstract hook（exact / normalized / fuzzy / apply_match / apply_unmatched）+ 共用 batch loop 與 candidate cache；既有 `PartyReconciliationService` 改門面 dispatch by `party_type`（公開 method 簽章 0 行變動），DI Factory 加兩個 reconciler；A2 `_confirm_*_flow` 補 `reconcile()` 呼叫；ParsedParty 加 `match_method` + `match_confidence` 兩個 optional 欄位（default UNMATCHED / 0.0）。

**Tech Stack:** Python 3.11 / SQLAlchemy / dependency-injector / pytest / openpyxl / cucumber.js（test repo）

---

> **Phase**：A3（Track A 第三階段）
> **級別**：重型（brainstorm → design → plan → 開工）
> **依賴**：A2 BE 已 shipped（9 commits + 205 test 全綠）+ design-A3.md spec review approved
> **預計工時**：BE ~2.75d + cucumber 0.5d ≈ **3.25 working day**（不含 plan/design 寫作）
> **對應 design**：`docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A3.md`（13 章）
> **執行階段切分（5 session）**：Session A (design / done) → Session B (plan / self) → Session C (T0+T1+T2) → Session D (T3+T4) → Session E (T5+T6+T7) — 對齊 design-A3 §12

---

## Task 切分概覽

| Session | Task | 主題 | repo | 預估 | 依賴 | Commit checkpoint |
|---------|------|------|------|------|------|-------------------|
| C | T0 | Implementer pre-flight verification（補 Session A 未驗的 3 項） | BE | 0.1d | — | 併入 T1 commit (`feat(oscal): A3 T1 base + reconcilers skeleton`) |
| C | T1 | 抽 `BaseReconciliationService` + `MatchMethod` enum + `_normalizers` + `PersonReconciler` skeleton + `OrganizationReconciler` skeleton + `ParsedParty` 加欄位 | BE | 0.6d | T0 | `feat(oscal): A3 T1 base + reconcilers skeleton` |
| C | T2 | `PartyReconciliationService` 改門面 + DI wiring + 既有 8 unit test 全綠保住 | BE | 0.4d | T1 | `feat(oscal): A3 T2 facade + DI wiring` |
| D | T3 | A2 `_confirm_superset_flow` + `_confirm_update_flow` 補 `reconcile()` 呼叫 | BE | 0.25d | T2 | `feat(oscal): A3 T3 wire A2 confirm flows to reconciliation` |
| D | T4 | Fuzzy 演算法完整落地（PersonReconciler 三階段 + OrganizationReconciler 三階段 + candidate cache）| BE | 0.5d | T1 | `feat(oscal): A3 T4 fuzzy match algorithm` |
| E | T5 | Unit test 全套（13 normalizers + 7 base + 12 person + 10 org + 3 facade + 4 A2 integration + 4 ParsedParty + ~2 matrix gap = ~51 case）+ A2 串接 integration | BE | 0.75d | T2, T3, T4 | `test(oscal): A3 T5 unit + integration tests` |
| E | T6 | Cucumber regression `05-ssp-docx-import-party-match.feature` 5 scenarios | test | 0.25d | T5 | `test(compliance-manager-test): A3 T6 cucumber party match regression` |
| E | T7 | Changelog + tracker + design §11 reconciliation + final SUMMARY | BE | 0.4d | T6 | `docs(ssp-import-export-phase2): A3 收尾 + SUMMARY` |

> **並行最佳化**：
> - T1 是 T2/T4 共同基礎，必須先做。
> - T2 結束（門面化 + DI）之後 T3、T4 可平行；T4 不依賴 T3（T3 是 caller-side 串接，T4 是演算法）。
> - T5 必須等 T2 / T3 / T4 都完成（test 對齊最終實作）。
> - **Session 切分（不可平行）**：Session B → C → D → E 線性推進；每 Session 結束產 handoff 給下一 Session。

---

## File Structure

### 新建檔案（A3）

```
domain/oscal/service/reconciliation/                      # 新 sub-folder
├── __init__.py                                            # 對外 export PersonReconciler / OrganizationReconciler / MatchMethod
├── base.py                                                # BaseReconciliationService[TParsed, TEntity] (~80 lines)
├── match_method.py                                        # MatchMethod StrEnum (~10 lines)
├── _normalizers.py                                        # _strip_plus_alias / _normalize_name / _strip_org_suffix (~40 lines)
├── person_reconciler.py                                   # PersonReconciler (~70 lines)
└── organization_reconciler.py                             # OrganizationReconciler (~60 lines)

tests/test_a3_reconciliation_base.py                      # base class hook contract + cache 行為
tests/test_a3_reconciliation_person.py                    # PersonReconciler 三階段
tests/test_a3_reconciliation_organization.py              # OrganizationReconciler 三階段
tests/test_a3_reconciliation_facade.py                    # 門面 dispatch 行為
tests/test_a3_reconciliation_normalizers.py               # _normalizers 純函式邊界 case
tests/test_a3_reconciliation_a2_integration.py            # A2 _confirm_*_flow 串接 reconcile

docs/changelog/YYYY-MM-DD-feat-party-matcher-shared.md   # type: feat、modules: oscal
```

### 改動既有檔案（A3）

```
domain/oscal/service/party_reconciliation_service.py     # 改門面（行數從 97 → ~30，公開 method 簽章不變）
domain/oscal/parser/ssp_intermediate.py                  # ParsedParty 加 match_method + match_confidence
app/oscal/service/ssp_excel_import_app_service.py        # _confirm_superset_flow:251 + _confirm_update_flow:325 補 reconcile()
di_containers/oscal/oscal_containers.py                  # 加 person_reconciler / organization_reconciler Factory；門面改注入兩 reconciler
docs/features/FR-011.2-2605-ssp-import-export-phase2/README.md         # tracker A3 row pending → shipped
docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A3.md      # §11 Implementation Reality / Reconciliation 段補實作偏差
```

### 跨 repo 改動

```
compliance-manager-test/
└── features/regression/module-frame/05-ssp-docx-import-party-match.feature   # 5 scenarios
   + steps/module-frame/ssp-docx-import-party-match.steps.js                  # 對應 step
   + pages/module-frame/ssp-docx-import-page.js                               # page object 補 selector（若必要）
```

### 不動

- jedi-* 套件（reconciliation 維持在主專案；F4 follow-up 才可能動 jedi-auth）
- FE（match_method JSONB schema 已 free shape，A5 phase 才補預覽 UI 互動）

---

## Task 0 — Implementer Pre-flight Verification

> **Session A 已驗的 5 項（design-A3 §7）implementer 不重做**：
> 1. PartyReconciliationService caller 盤點 ✅
> 2. A2 confirm flow 沒實際串 reconcile ✅
> 3. jedi-auth Query Entity 既有欄位（無 email_domain / name prefix）✅
> 4. cucumber 既有 docx import 無 matched/unmatched 守門 ✅
> 5. test repo 結構 ✅
>
> **T0 補 implementer 開工瞬間才會碰到的 3 項**（plan 寫好到 Session C 動工可能跨 days/weeks，期間 method 改名 / entity shape 變）。

### 0.1 verify `PartyReconciliationService` 既有實作行為未被人改動

```bash
grep -n "def reconcile\|def __init__\|def _reconcile_person\|def _reconcile_organization" \
  domain/oscal/service/party_reconciliation_service.py
```

**期待結果**：仍是 design-A3 §1.1 / §3.2 描述的兩 method（person email exact / organization name exact）+ `__init__(user_domain_service, org_unit_domain_service)`；row count 仍 ~97。若被 patch 過，要回頭看 design-A3 §11 是否需 reconcile。

### 0.2 verify A2 `_confirm_superset_flow` + `_confirm_update_flow` 仍未呼叫 reconcile

```bash
grep -nE "reconcile|reconciliation" app/oscal/service/ssp_excel_import_app_service.py | head -20
```

**期待結果**：只看到 `__init__` 內 `party_reconciliation_service` 注入（line 50/59），`_confirm_*_flow` body 內**沒有**任何 `.reconcile(` 呼叫。若已被別人補上，T3 task 可標 N/A 直接合進 changelog。

### 0.3 verify `ParsedParty` dataclass 既有欄位

```bash
grep -nA 20 "^class ParsedParty" domain/oscal/parser/ssp_intermediate.py
```

**期待結果**：dataclass 含 `name / party_type / role / email_address / matched_user_id / matched_org_unit_id / target_party_uid`（design-A3 §4.2 假設）。T1 加新欄位前若已被別人補過 `match_method` / `match_confidence`，停下對齊命名後續執行。

### 0.4 Acceptance

- [ ] 3 個 verify 結果與 design-A3 §1 / §4 假設一致 → 進 T1
- [ ] 若任一項不符 → 主動在 design-A3 §11 補 Reconciliation 條目，**不照舊硬幹**

> **何時 commit**：T0 結果可併入 T1 commit；無 standalone commit 必要。

---

## Task 1 — BaseReconciliationService + Reconcilers Skeleton + ParsedParty 加欄位

> **目標**：把 design-A3 §3.2 / §3.3 / §4 落地成可被 import 的 5 個 Python module。
> **TDD 邊界**：base class hook contract + reconciler skeleton 都先寫 test 後寫 code；fuzzy 演算法本身放 T4 落地（此 task 三 abstract method 只需 minimal skeleton 讓 import 不抛）。

### 1.1 新建 `MatchMethod` enum

**Files:**
- Create: `domain/oscal/service/reconciliation/__init__.py`
- Create: `domain/oscal/service/reconciliation/match_method.py`
- Test: `tests/test_a3_reconciliation_normalizers.py`（後續 T1.4 一起寫）

- [ ] **Step 1**: 建 sub-folder + 空 `__init__.py`

```bash
mkdir -p domain/oscal/service/reconciliation
touch domain/oscal/service/reconciliation/__init__.py
```

- [ ] **Step 2**: 寫 `match_method.py`

```python
# domain/oscal/service/reconciliation/match_method.py
from enum import StrEnum


class MatchMethod(StrEnum):
    EXACT = "exact"
    NORMALIZED = "normalized"
    FUZZY_EMAIL_DOMAIN = "fuzzy_email_domain"
    FUZZY_NAME_PREFIX = "fuzzy_name_prefix"
    UNMATCHED = "unmatched"
```

- [ ] **Step 3**: import smoke

```bash
poetry run python3 -c "from domain.oscal.service.reconciliation.match_method import MatchMethod; print(MatchMethod.EXACT)"
```

Expected: `MatchMethod.EXACT`

### 1.2 寫 `_normalizers.py`（純函式 + 內聯 unit test 先驅動）

**Files:**
- Create: `domain/oscal/service/reconciliation/_normalizers.py`
- Test: `tests/test_a3_reconciliation_normalizers.py`

- [ ] **Step 1**: 先寫 failing test（TDD）

```python
# tests/test_a3_reconciliation_normalizers.py
import pytest
from domain.oscal.service.reconciliation._normalizers import (
    _strip_plus_alias, _normalize_name, _strip_org_suffix
)


class TestStripPlusAlias:
    def test_simple_plus_alias(self):
        assert _strip_plus_alias("alice+work@acme.com") == "alice@acme.com"

    def test_no_plus(self):
        assert _strip_plus_alias("alice@acme.com") == "alice@acme.com"

    def test_no_at(self):
        assert _strip_plus_alias("invalid") == "invalid"

    def test_plus_in_domain_not_local(self):
        # +在 domain side 不該 strip
        assert _strip_plus_alias("alice@a+b.com") == "alice@a+b.com"

    def test_multiple_at(self):
        # split('@', 1) 取第一個 @
        assert _strip_plus_alias("alice+work@b@c.com") == "alice@b@c.com"


class TestNormalizeName:
    def test_full_width_space(self):
        assert _normalize_name("陳　小明") == "陳 小明"

    def test_collapse_spaces(self):
        assert _normalize_name("陳   小明") == "陳 小明"

    def test_strip_outer(self):
        assert _normalize_name("  Alice  ") == "Alice"

    def test_none(self):
        assert _normalize_name(None) == ""

    def test_empty(self):
        assert _normalize_name("") == ""


class TestStripOrgSuffix:
    def test_股份有限公司(self):
        assert _strip_org_suffix("ACME 股份有限公司") == "ACME"

    def test_有限公司_not_eaten_first(self):
        # 順序檢查：「股份有限公司」優先於「有限公司」
        assert _strip_org_suffix("台積電股份有限公司") == "台積電"

    def test_inc(self):
        assert _strip_org_suffix("Foo Inc.") == "Foo"

    def test_no_suffix(self):
        assert _strip_org_suffix("ACME") == "ACME"

    def test_empty(self):
        assert _strip_org_suffix("") == ""
```

- [ ] **Step 2**: 跑 test 確認 FAIL（module 還沒建）

```bash
pytest tests/test_a3_reconciliation_normalizers.py -v
```

Expected: `ModuleNotFoundError: No module named 'domain.oscal.service.reconciliation._normalizers'`

- [ ] **Step 3**: 寫 `_normalizers.py`（對齊 design-A3 §5.3）

```python
# domain/oscal/service/reconciliation/_normalizers.py
from typing import Optional


def _strip_plus_alias(email: str) -> str:
    if '@' not in email:
        return email
    local, domain = email.split('@', 1)
    if '+' not in local:
        return email
    return f"{local.split('+', 1)[0]}@{domain}"


def _normalize_name(name: Optional[str]) -> str:
    if not name:
        return ""
    s = name.replace('　', ' ')
    s = ' '.join(s.split())
    return s.strip()


ORG_SUFFIXES = [
    "股份有限公司",
    "有限公司",
    "公司",
    "Co., Ltd.", "Co., Ltd",
    "Corp.", "Corp",
    "L.L.C.", "LLC",
    "Ltd.", "Ltd",
    "Inc.", "Inc",
]


def _strip_org_suffix(name: str) -> str:
    if not name:
        return name
    for suffix in ORG_SUFFIXES:
        if name.endswith(suffix):
            return name[:-len(suffix)].strip()
    return name
```

- [ ] **Step 4**: 跑 test 全綠

```bash
pytest tests/test_a3_reconciliation_normalizers.py -v
```

Expected: 13 passed

### 1.3 寫 `BaseReconciliationService` 抽象基類

**Files:**
- Create: `domain/oscal/service/reconciliation/base.py`
- Test: `tests/test_a3_reconciliation_base.py`

- [ ] **Step 1**: 先寫 failing test（cache + 三階段 fallback flow）

```python
# tests/test_a3_reconciliation_base.py
import pytest
from unittest.mock import MagicMock
from domain.oscal.service.reconciliation.base import BaseReconciliationService
from domain.oscal.service.reconciliation.match_method import MatchMethod


class _DummyReconciler(BaseReconciliationService):
    """測試用 reconciler — 5 hook 都 MagicMock-style 受控"""

    def __init__(self):
        super().__init__()
        self.exact_returns = None
        self.normalized_returns = None
        self.fuzzy_returns = (None, MatchMethod.UNMATCHED)
        self.applied_matches = []
        self.applied_unmatched = []

    def _try_exact_match(self, parsed, tenant_id):
        return self.exact_returns

    def _try_normalized_match(self, parsed, tenant_id):
        return self.normalized_returns

    def _try_fuzzy_match(self, parsed, tenant_id):
        return self.fuzzy_returns

    def _apply_match(self, parsed, entity, method, confidence):
        self.applied_matches.append((parsed, entity, method, confidence))

    def _apply_unmatched(self, parsed):
        self.applied_unmatched.append(parsed)


def test_exact_hit_short_circuits():
    r = _DummyReconciler()
    entity = MagicMock(name="user_42")
    r.exact_returns = entity
    parsed = MagicMock(name="parsed_alice")
    r.reconcile([parsed], tenant_id=102)
    assert r.applied_matches == [(parsed, entity, MatchMethod.EXACT, 1.0)]
    assert r.applied_unmatched == []


def test_normalized_hit_when_exact_miss():
    r = _DummyReconciler()
    entity = MagicMock()
    r.normalized_returns = entity
    parsed = MagicMock()
    r.reconcile([parsed], tenant_id=102)
    assert r.applied_matches == [(parsed, entity, MatchMethod.NORMALIZED, 1.0)]


def test_fuzzy_hit_uses_returned_method_and_low_confidence():
    r = _DummyReconciler()
    entity = MagicMock()
    r.fuzzy_returns = (entity, MatchMethod.FUZZY_EMAIL_DOMAIN)
    parsed = MagicMock()
    r.reconcile([parsed], tenant_id=102)
    assert r.applied_matches == [(parsed, entity, MatchMethod.FUZZY_EMAIL_DOMAIN, 0.7)]


def test_unmatched_when_all_stages_miss():
    r = _DummyReconciler()
    parsed = MagicMock()
    r.reconcile([parsed], tenant_id=102)
    assert r.applied_unmatched == [parsed]
    assert r.applied_matches == []


def test_batch_iterates_all_records():
    r = _DummyReconciler()
    parsed_a, parsed_b = MagicMock(), MagicMock()
    result = r.reconcile([parsed_a, parsed_b], tenant_id=102)
    assert result == [parsed_a, parsed_b]
    assert len(r.applied_unmatched) == 2


def test_cache_lazy_loads_once():
    r = _DummyReconciler()
    loader = MagicMock(return_value=[1, 2, 3])
    assert r._get_or_load_candidates(loader) == [1, 2, 3]
    assert r._get_or_load_candidates(loader) == [1, 2, 3]
    assert loader.call_count == 1


def test_cache_exception_returns_empty_list_no_persist():
    r = _DummyReconciler()
    loader = MagicMock(side_effect=RuntimeError("DB down"))
    result = r._get_or_load_candidates(loader)
    assert result == []
    assert r._candidate_cache is None  # 不持久化 exception 結果
    # 下次 call 仍 retry
    loader_ok = MagicMock(return_value=[42])
    assert r._get_or_load_candidates(loader_ok) == [42]
    assert loader_ok.call_count == 1
```

- [ ] **Step 2**: 跑 test 確認 FAIL

```bash
pytest tests/test_a3_reconciliation_base.py -v
```

Expected: `ModuleNotFoundError`

- [ ] **Step 3**: 寫 `base.py`（對齊 design-A3 §3.2）

```python
# domain/oscal/service/reconciliation/base.py
from abc import ABC, abstractmethod
from typing import Generic, List, Optional, Tuple, TypeVar

from domain.oscal.service.reconciliation.match_method import MatchMethod

TParsed = TypeVar("TParsed")
TEntity = TypeVar("TEntity")


class BaseReconciliationService(ABC, Generic[TParsed, TEntity]):
    """Domain-level base class for parsed-record ↔ system-entity reconciliation.

    Each subclass owns one domain pair (e.g. ParsedParty(person) ↔ User);
    subclass implements 5 hooks. The base class owns the shared batch loop,
    candidate cache, and three-state fallback (exact / normalized / fuzzy).

    Caller wraps the call in @transaction; this class never opens a session.
    """

    def __init__(self):
        self._candidate_cache: Optional[List[TEntity]] = None

    def reconcile(
        self,
        parsed_list: List[TParsed],
        tenant_id: Optional[int] = None,
    ) -> List[TParsed]:
        for parsed in parsed_list:
            self._reconcile_one(parsed, tenant_id)
        return parsed_list

    def _reconcile_one(self, parsed: TParsed, tenant_id: Optional[int]) -> None:
        candidate = self._try_exact_match(parsed, tenant_id)
        if candidate is not None:
            self._apply_match(parsed, candidate, MatchMethod.EXACT, 1.0)
            return
        candidate = self._try_normalized_match(parsed, tenant_id)
        if candidate is not None:
            self._apply_match(parsed, candidate, MatchMethod.NORMALIZED, 1.0)
            return
        candidate, fuzzy_method = self._try_fuzzy_match(parsed, tenant_id)
        if candidate is not None:
            self._apply_match(parsed, candidate, fuzzy_method, 0.7)
            return
        self._apply_unmatched(parsed)

    @abstractmethod
    def _try_exact_match(
        self, parsed: TParsed, tenant_id: Optional[int]
    ) -> Optional[TEntity]: ...

    @abstractmethod
    def _try_normalized_match(
        self, parsed: TParsed, tenant_id: Optional[int]
    ) -> Optional[TEntity]: ...

    @abstractmethod
    def _try_fuzzy_match(
        self, parsed: TParsed, tenant_id: Optional[int]
    ) -> Tuple[Optional[TEntity], MatchMethod]: ...

    @abstractmethod
    def _apply_match(
        self,
        parsed: TParsed,
        entity: TEntity,
        method: MatchMethod,
        confidence: float,
    ) -> None: ...

    @abstractmethod
    def _apply_unmatched(self, parsed: TParsed) -> None: ...

    def _get_or_load_candidates(self, loader) -> List[TEntity]:
        if self._candidate_cache is not None:
            return self._candidate_cache
        try:
            result = loader() or []
        except Exception:
            return []
        self._candidate_cache = result
        return result
```

- [ ] **Step 4**: 跑 test 全綠

```bash
pytest tests/test_a3_reconciliation_base.py -v
```

Expected: 7 passed

### 1.4 ParsedParty 加 `match_method` + `match_confidence` 欄位

**Files:**
- Modify: `domain/oscal/parser/ssp_intermediate.py`
- Test: 既有 `tests/test_party_reconciliation_service.py` + 任何用到 `ParsedParty(...)` 的 fixture 必須仍綠

- [ ] **Step 1**: 找 ParsedParty 既有 fixture 量

```bash
grep -rn "ParsedParty(" tests/ app/ domain/ | head -20
```

- [ ] **Step 2**: 加欄位（兩個 optional + default）

```python
# domain/oscal/parser/ssp_intermediate.py
# 既有 import
from domain.oscal.service.reconciliation.match_method import MatchMethod

@dataclass
class ParsedParty:
    name: str
    party_type: Literal["person", "organization"]
    role: Optional[str]
    email_address: Optional[str]
    # ... 其他既有欄位
    matched_user_id: Optional[int] = None
    matched_org_unit_id: Optional[int] = None
    target_party_uid: Optional[str] = None

    # A3 新增
    match_method: MatchMethod = MatchMethod.UNMATCHED
    match_confidence: float = 0.0
```

- [ ] **Step 3**: 跑既有 unit test 全綠（fixture 不破壞驗證）

```bash
pytest tests/test_party_reconciliation_service.py tests/test_ssp_docx_*.py tests/test_ssp_excel_*.py -v 2>&1 | tail -30
```

Expected: 既有測試（A1 106 + A2 99 + party 8 = 213）全綠

### 1.5 寫 PersonReconciler skeleton

**Files:**
- Create: `domain/oscal/service/reconciliation/person_reconciler.py`
- Test: `tests/test_a3_reconciliation_person.py`

> **T1.5 只做 skeleton + exact match stage**；normalized + fuzzy 階段在 **T4** 完整落地。先 skeleton 是為了讓 T2 門面化能 import / DI / 跑通既有 8 exact test。

- [ ] **Step 1**: 先寫 failing test（只測 exact stage）

```python
# tests/test_a3_reconciliation_person.py
import pytest
from unittest.mock import MagicMock
from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.reconciliation.person_reconciler import PersonReconciler
from domain.oscal.service.reconciliation.match_method import MatchMethod


@pytest.fixture
def user_domain():
    m = MagicMock()
    m.get_users.return_value = []
    return m


@pytest.fixture
def reconciler(user_domain):
    return PersonReconciler(user_domain_service=user_domain)


def _parsed(name="Alice", email="alice@acme.com"):
    return ParsedParty(
        name=name, party_type="person", role=None, email_address=email,
    )


class TestExactStage:
    def test_email_lowercase_trim_exact_hit(self, reconciler, user_domain):
        user = MagicMock(id=42, is_active=True, email="alice@acme.com")
        user_domain.get_users.return_value = [user]
        p = _parsed(email="  Alice@Acme.com  ")
        reconciler.reconcile([p], tenant_id=102)
        assert p.matched_user_id == 42
        assert p.match_method == MatchMethod.EXACT
        assert p.match_confidence == 1.0

    def test_inactive_user_skipped(self, reconciler, user_domain):
        user = MagicMock(id=42, is_active=False, email="alice@acme.com")
        user_domain.get_users.return_value = [user]
        p = _parsed()
        reconciler.reconcile([p], tenant_id=102)
        assert p.matched_user_id is None
        assert p.match_method == MatchMethod.UNMATCHED
        assert p.match_confidence == 0.0

    def test_empty_email_skipped(self, reconciler):
        p = _parsed(email=None)
        reconciler.reconcile([p], tenant_id=102)
        assert p.match_method == MatchMethod.UNMATCHED

    def test_exception_from_domain_swallowed_to_unmatched(self, reconciler, user_domain):
        user_domain.get_users.side_effect = RuntimeError("DB down")
        p = _parsed()
        reconciler.reconcile([p], tenant_id=102)
        assert p.match_method == MatchMethod.UNMATCHED
```

- [ ] **Step 2**: 跑 test 確認 FAIL

- [ ] **Step 3**: 寫 PersonReconciler skeleton（exact stage 完整 + normalized/fuzzy 暫 stub）

```python
# domain/oscal/service/reconciliation/person_reconciler.py
from typing import List, Optional, Tuple

from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.reconciliation.base import BaseReconciliationService
from domain.oscal.service.reconciliation.match_method import MatchMethod


class PersonReconciler(BaseReconciliationService[ParsedParty, object]):
    def __init__(self, user_domain_service):
        super().__init__()
        self._user = user_domain_service

    def _try_exact_match(self, parsed, tenant_id):
        email = (parsed.email_address or "").strip().lower()
        if not email:
            return None
        try:
            # 對齊 既有 PartyReconciliationService Query Entity 簽章
            from jedi_auth.domain.entity.user_query_entity import UserQueryEntity
            users = self._user.get_users(UserQueryEntity(email=email))
        except Exception:
            return None
        for u in users or []:
            if getattr(u, "is_active", True):
                return u
        return None

    def _try_normalized_match(self, parsed, tenant_id):
        return None  # T4 落地

    def _try_fuzzy_match(self, parsed, tenant_id):
        return (None, MatchMethod.UNMATCHED)  # T4 落地

    def _apply_match(self, parsed, entity, method, confidence):
        parsed.matched_user_id = entity.id
        parsed.match_method = method
        parsed.match_confidence = confidence

    def _apply_unmatched(self, parsed):
        parsed.match_method = MatchMethod.UNMATCHED
        parsed.match_confidence = 0.0
        # matched_user_id 維持 None
```

- [ ] **Step 4**: 跑 test 全綠

### 1.6 寫 OrganizationReconciler skeleton（同 1.5 pattern，exact stage only）

**Files:**
- Create: `domain/oscal/service/reconciliation/organization_reconciler.py`
- Test: `tests/test_a3_reconciliation_organization.py`

- [ ] **Step 1**: 先寫 failing test（exact stage 5 case）

```python
# tests/test_a3_reconciliation_organization.py
import pytest
from unittest.mock import MagicMock
from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.reconciliation.organization_reconciler import OrganizationReconciler
from domain.oscal.service.reconciliation.match_method import MatchMethod


@pytest.fixture
def org_unit_domain():
    m = MagicMock()
    m.get_org_units.return_value = []
    return m


@pytest.fixture
def reconciler(org_unit_domain):
    return OrganizationReconciler(org_unit_domain_service=org_unit_domain)


def _parsed(name="ACME"):
    return ParsedParty(
        name=name, party_type="organization", role=None, email_address=None,
    )


class TestExactStage:
    def test_name_trim_exact_hit(self, reconciler, org_unit_domain):
        org = MagicMock(id=7)
        org_unit_domain.get_org_units.return_value = [org]
        p = _parsed(name="  ACME  ")
        reconciler.reconcile([p], tenant_id=102)
        assert p.matched_org_unit_id == 7
        assert p.match_method == MatchMethod.EXACT
        assert p.match_confidence == 1.0

    def test_empty_name(self, reconciler):
        p = _parsed(name="")
        reconciler.reconcile([p], tenant_id=102)
        assert p.match_method == MatchMethod.UNMATCHED

    def test_exception_swallowed(self, reconciler, org_unit_domain):
        org_unit_domain.get_org_units.side_effect = RuntimeError("DB")
        p = _parsed()
        reconciler.reconcile([p], tenant_id=102)
        assert p.match_method == MatchMethod.UNMATCHED

    def test_no_hits(self, reconciler):
        p = _parsed()
        reconciler.reconcile([p], tenant_id=102)
        assert p.match_method == MatchMethod.UNMATCHED
        assert p.matched_org_unit_id is None

    def test_tenant_id_passed_when_provided(self, reconciler, org_unit_domain):
        p = _parsed()
        reconciler.reconcile([p], tenant_id=102)
        # 確認 query 帶 tenant_id（沿用 既有 PartyReconciliationService 行為）
        call_args = org_unit_domain.get_org_units.call_args
        assert call_args is not None
        # 看 Query Entity 內 tenant_id 是否被設（依 既有 Query Entity 介面）
```

- [ ] **Step 2**: 跑 test 確認 FAIL

- [ ] **Step 3**: 寫 OrganizationReconciler skeleton

```python
# domain/oscal/service/reconciliation/organization_reconciler.py
from typing import Optional, Tuple

from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.reconciliation.base import BaseReconciliationService
from domain.oscal.service.reconciliation.match_method import MatchMethod


class OrganizationReconciler(BaseReconciliationService[ParsedParty, object]):
    def __init__(self, org_unit_domain_service):
        super().__init__()
        self._org = org_unit_domain_service

    def _try_exact_match(self, parsed, tenant_id):
        name = (parsed.name or "").strip()
        if not name:
            return None
        try:
            from jedi_auth.domain.entity.org_unit_query_entity import OrgUnitQueryEntity
            q = OrgUnitQueryEntity(name=name)
            if tenant_id is not None:
                q.tenant_id = tenant_id
            orgs = self._org.get_org_units(q)
        except Exception:
            return None
        return orgs[0] if orgs else None

    def _try_normalized_match(self, parsed, tenant_id):
        return None  # T4 落地

    def _try_fuzzy_match(self, parsed, tenant_id):
        return (None, MatchMethod.UNMATCHED)  # T4 落地

    def _apply_match(self, parsed, entity, method, confidence):
        parsed.matched_org_unit_id = entity.id
        parsed.match_method = method
        parsed.match_confidence = confidence

    def _apply_unmatched(self, parsed):
        parsed.match_method = MatchMethod.UNMATCHED
        parsed.match_confidence = 0.0
```

- [ ] **Step 4**: 跑 test 全綠

### 1.7 補 `__init__.py` export

```python
# domain/oscal/service/reconciliation/__init__.py
from domain.oscal.service.reconciliation.base import BaseReconciliationService
from domain.oscal.service.reconciliation.match_method import MatchMethod
from domain.oscal.service.reconciliation.person_reconciler import PersonReconciler
from domain.oscal.service.reconciliation.organization_reconciler import OrganizationReconciler

__all__ = [
    "BaseReconciliationService",
    "MatchMethod",
    "PersonReconciler",
    "OrganizationReconciler",
]
```

### 1.8 Commit T1

- [ ] **Step 1**: git status 確認檔案清單

```bash
git status --short
```

預期看到：
- ?? domain/oscal/service/reconciliation/__init__.py
- ?? domain/oscal/service/reconciliation/base.py
- ?? domain/oscal/service/reconciliation/match_method.py
- ?? domain/oscal/service/reconciliation/_normalizers.py
- ?? domain/oscal/service/reconciliation/person_reconciler.py
- ?? domain/oscal/service/reconciliation/organization_reconciler.py
- ?? tests/test_a3_reconciliation_normalizers.py
- ?? tests/test_a3_reconciliation_base.py
- ?? tests/test_a3_reconciliation_person.py
- ?? tests/test_a3_reconciliation_organization.py
- M  domain/oscal/parser/ssp_intermediate.py

- [ ] **Step 2**: 顯式 git add（**禁 `-A` / `-am`** — 沿用既有鐵律）

```bash
git add domain/oscal/service/reconciliation/__init__.py \
        domain/oscal/service/reconciliation/base.py \
        domain/oscal/service/reconciliation/match_method.py \
        domain/oscal/service/reconciliation/_normalizers.py \
        domain/oscal/service/reconciliation/person_reconciler.py \
        domain/oscal/service/reconciliation/organization_reconciler.py \
        domain/oscal/parser/ssp_intermediate.py \
        tests/test_a3_reconciliation_normalizers.py \
        tests/test_a3_reconciliation_base.py \
        tests/test_a3_reconciliation_person.py \
        tests/test_a3_reconciliation_organization.py
```

- [ ] **Step 3**: commit

```bash
git commit -m "$(cat <<'EOF'
feat(oscal): A3 T1 base + reconcilers skeleton

抽 BaseReconciliationService[TParsed, TEntity] 抽象基類 + Person /
Organization Reconciler skeleton（exact stage 完整、normalized/fuzzy 留 T4）；
ParsedParty 加 match_method + match_confidence optional 欄位；
13 + 7 + 4 + 5 = 29 個 unit test 全綠。

對齊 design-A3.md §3.2 / §4 / §5.3。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

### Task 1 Acceptance

- [ ] 6 個新檔可被 import 不抛
- [ ] `MatchMethod` / `BaseReconciliationService` / `PersonReconciler` / `OrganizationReconciler` import smoke 通過
- [ ] _normalizers.py 13 個 test 全綠
- [ ] base.py 7 個 test 全綠
- [ ] person_reconciler.py 4 個 test 全綠（exact stage）
- [ ] organization_reconciler.py 5 個 test 全綠（exact stage）
- [ ] 既有 213 test（A1 106 + A2 99 + party 8）仍全綠

---

## Task 2 — PartyReconciliationService 改門面 + DI Wiring

### 2.1 改門面實作

**Files:**
- Modify: `domain/oscal/service/party_reconciliation_service.py`（97 → ~30 行）

- [ ] **Step 1**: 把既有 97 行內部邏輯改成 dispatch

```python
# domain/oscal/service/party_reconciliation_service.py
from typing import List, Optional

from domain.oscal.parser.ssp_intermediate import ParsedParty


class PartyReconciliationService:
    """Facade dispatching ParsedParty list by party_type.

    Mixed-list dispatch is domain knowledge — callers (docx / A2 confirm flows)
    should not need to know how to split persons / organizations.
    """

    def __init__(self, person_reconciler, organization_reconciler):
        self._person = person_reconciler
        self._org = organization_reconciler

    def reconcile(
        self,
        parties: List[ParsedParty],
        tenant_id: Optional[int] = None,
    ) -> List[ParsedParty]:
        persons = [p for p in parties if p.party_type == "person"]
        orgs = [p for p in parties if p.party_type == "organization"]
        self._person.reconcile(persons, tenant_id)
        self._org.reconcile(orgs, tenant_id)
        return parties
```

- [ ] **Step 2**: 跑既有 8 個 party reconciliation test

```bash
pytest tests/test_party_reconciliation_service.py -v
```

**Expected**：8 個 case 全綠（**0 行測試 code 改動** — 因為公開 method 簽章 100% 不變、行為 100% 不變 via PersonReconciler/OrganizationReconciler 的 exact stage）。

若有 fail：**要先檢查既有 test 是否用了 `service._user_domain_service` 直接存取或 `_reconcile_person` 私有 method**（如果是，更新 test 改用 mock injection）。

### 2.2 DI Wiring

**Files:**
- Modify: `di_containers/oscal/oscal_containers.py`

- [ ] **Step 1**: 查既有 line 488 `party_reconciliation_service` provider

```bash
grep -nA 5 "party_reconciliation_service\|person_reconciler\|organization_reconciler" \
  di_containers/oscal/oscal_containers.py | head -30
```

- [ ] **Step 1b**: verify `auth_container` 已 import（pattern 已存在於 line 490/491/522/523/579/580 6 處）

```bash
grep -nE "^from|^import" di_containers/oscal/oscal_containers.py | grep -i auth
```

**期待結果**：看到 `from di_containers.auth.auth_containers import AuthContainer`（或 sub-container reference 樣式）+ 既有 `auth_container.user_domain_service` / `auth_container.org_unit_domain_service` 6 處引用都正常解析。若沒看到 import，先補上再進 Step 2。

- [ ] **Step 2**: 加新兩 Factory + 改門面 wiring

```python
# di_containers/oscal/oscal_containers.py
# 在 既有 party_reconciliation_service provider 前加：

from domain.oscal.service.reconciliation.person_reconciler import PersonReconciler
from domain.oscal.service.reconciliation.organization_reconciler import OrganizationReconciler

person_reconciler = providers.Factory(
    PersonReconciler,
    user_domain_service=auth_container.user_domain_service,
)
organization_reconciler = providers.Factory(
    OrganizationReconciler,
    org_unit_domain_service=auth_container.org_unit_domain_service,
)

# 改既有 line 488 provider：
party_reconciliation_service = providers.Factory(
    PartyReconciliationService,
    person_reconciler=person_reconciler,
    organization_reconciler=organization_reconciler,
)
```

- [ ] **Step 3**: BE 起動 smoke（不 hot reload，必 kill -9）

```bash
lsof -ti:8000 | xargs kill -9 2>/dev/null
set -a; source .env; set +a
nohup poetry run python main_app.py > /tmp/a3-be-boot.log 2>&1 &
sleep 5
tail -50 /tmp/a3-be-boot.log
grep -E "ERROR|Traceback|Failed" /tmp/a3-be-boot.log | head -20
```

**Expected**：BE 起動成功，無 DI wire error。
**若撞 jedi-issue GitLab env 問題**（A0.1 follow-up #7）：partial smoke 接受 — 確認 DI container build 不 wire error 即可。

### 2.3 寫門面 unit test

**Files:**
- Create: `tests/test_a3_reconciliation_facade.py`

- [ ] **Step 1**: 寫 3 個 dispatch test

```python
# tests/test_a3_reconciliation_facade.py
import pytest
from unittest.mock import MagicMock
from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.party_reconciliation_service import PartyReconciliationService


@pytest.fixture
def person_rec():
    m = MagicMock()
    m.reconcile.side_effect = lambda lst, tenant_id=None: lst
    return m


@pytest.fixture
def org_rec():
    m = MagicMock()
    m.reconcile.side_effect = lambda lst, tenant_id=None: lst
    return m


@pytest.fixture
def facade(person_rec, org_rec):
    return PartyReconciliationService(
        person_reconciler=person_rec,
        organization_reconciler=org_rec,
    )


def test_dispatch_by_party_type(facade, person_rec, org_rec):
    persons = [ParsedParty(name="A", party_type="person", role=None, email_address="a@x.com")]
    orgs = [ParsedParty(name="X", party_type="organization", role=None, email_address=None)]
    facade.reconcile(persons + orgs, tenant_id=102)
    person_rec.reconcile.assert_called_once_with(persons, 102)
    org_rec.reconcile.assert_called_once_with(orgs, 102)


def test_empty_list(facade, person_rec, org_rec):
    result = facade.reconcile([], tenant_id=102)
    assert result == []
    person_rec.reconcile.assert_called_once_with([], 102)
    org_rec.reconcile.assert_called_once_with([], 102)


def test_returns_same_list_reference(facade):
    parties = [ParsedParty(name="A", party_type="person", role=None, email_address="a@x.com")]
    result = facade.reconcile(parties, tenant_id=102)
    assert result is parties
```

- [ ] **Step 2**: 跑 test 全綠

### 2.4 Commit T2

```bash
git add domain/oscal/service/party_reconciliation_service.py \
        di_containers/oscal/oscal_containers.py \
        tests/test_a3_reconciliation_facade.py

git commit -m "$(cat <<'EOF'
feat(oscal): A3 T2 facade + DI wiring

PartyReconciliationService 改門面 dispatch by party_type；新增 DI Factory
person_reconciler + organization_reconciler；門面改注入兩 reconciler。
既有 8 unit test 0 行改動全綠；門面新增 3 個 dispatch test 全綠。
BE 起動 smoke 通過 — DI container build 無 wire error。

對齊 design-A3.md §3.3 / §6.4。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

### Task 2 Acceptance

- [ ] PartyReconciliationService 公開 method `reconcile(parties, tenant_id)` 簽章不變
- [ ] 既有 8 unit test 全綠（0 行 test code 改動）
- [ ] DI container 含 person_reconciler / organization_reconciler / party_reconciliation_service 三 Factory
- [ ] BE 起動成功（或 partial smoke pass — wire 無 error）
- [ ] 門面 3 個新 dispatch test 全綠
- [ ] **docx parse-time exception path 行為對齊**：既有 `ssp_docx_import_app_service.py:258-264` 對 reconcile 失敗有 `try/except logger.warning` swallow；T2 門面化後行為仍 swallow（因為門面 100% 委派兩 reconciler，外部 exception path 不變）。但**fuzzy stage** 拉 candidate cache 失敗時，base.py `_get_or_load_candidates` 走「return [] 不寫 cache」**靜默路徑**（design-A3 §3.2 inline doc），這跟 line 264 `logger.warning` 行為**不一樣** — 屬可接受差異（base 內部 cache exception 不該污染 caller log），但在 design-A3 §11 補一條 reconciliation 條目說明此行為差異

---

## Task 3 — A2 Confirm Flow 補 reconcile() 串接

> **目標**：design-A3 §6.1 / §6.2 描述的 A2 path 串接 — `_confirm_superset_flow` + `_confirm_update_flow` 在 `write_parties` 前呼叫 reconcile。
> **A3 期間 fuzzy 行為**：A5 預覽 UI 還沒做 — A3 期間 fuzzy **自動寫入**（行為等同 matched）；A5 上線後改為 user 拍板（**follow-up F2** 已標 design §11）。

### 3.1 verify 既有 helper 位置

```bash
grep -n "_confirm_superset_flow\|_confirm_update_flow\|write_parties" \
  app/oscal/service/ssp_excel_import_app_service.py
```

對齊 plan-A2 / design-A2 §6 描述的 line 251 / 325 / write_parties context_type='module_frame'。

### 3.2 修 `_confirm_superset_flow`

**Files:**
- Modify: `app/oscal/service/ssp_excel_import_app_service.py`（line ~251）

- [ ] **Step 1**: 在 `write_parties` 之前加 reconcile

```python
def _confirm_superset_flow(self, job, parsed_result, decisions, overrides, user_context):
    # ... 既有 step 1~3（取 framework_version + catalog + 建 profile + 建 MF）

    # A3 新增：reconcile parties → 標 matched_user_id / matched_org_unit_id / match_method
    parties_combined = list(parsed_result.parties_org) + list(parsed_result.parties_person)
    self._reconciliation.reconcile(parties_combined, tenant_id=user_context.tenant_id)

    # 既有 _write_parties 呼叫不動：
    self._write_parties(parties_combined, context_type='module_frame', context_id=new_mf.id, user_context=user_context)
    # ... 既有 _write_items / _write_control_defaults / _write_ref_docs
```

> **注意**：實際 code 結構若已是「先呼叫 self._write_parties 把 dict list 餵進去」而不是接 ParsedParty dataclass list，要 verify 兩邊的 type — 如果 `_write_parties` 接 dict，**reconcile() 必須在 dict 化之前對 ParsedParty list 跑**。看 T0.2 verify 結果決定確切插入點。

### 3.3 修 `_confirm_update_flow`

**Files:**
- Modify: `app/oscal/service/ssp_excel_import_app_service.py`（line ~325）

- [ ] **Step 1**: 同 3.2 pattern

```python
def _confirm_update_flow(self, job, parsed_result, decisions, overrides, user_context):
    # ... 既有取 MF
    parties_combined = list(parsed_result.parties_org) + list(parsed_result.parties_person)
    self._reconciliation.reconcile(parties_combined, tenant_id=user_context.tenant_id)
    self._write_parties(parties_combined, context_type='module_frame', context_id=existing_mf.id, user_context=user_context)
    # ... 既有 upsert
```

### 3.4 寫 A2 integration test

**Files:**
- Create: `tests/test_a3_reconciliation_a2_integration.py`

- [ ] **Step 1**: 寫 fixture skeleton（對齊 既有 `tests/test_ssp_excel_import_app_service.py` 的 A2 mock pattern）

```python
# tests/test_a3_reconciliation_a2_integration.py
import pytest
from unittest.mock import MagicMock, patch
from contextlib import contextmanager

from app.oscal.service.ssp_excel_import_app_service import SspExcelImportAppService


@pytest.fixture
def patch_session_scope():
    """對齊 conftest.py 已有的 helper；若不存在則 inline 定義"""
    @contextmanager
    def _noop():
        yield
    with patch("jedi_common.session.database.db.session_scope", _noop):
        yield


@pytest.fixture
def mock_reconciliation():
    m = MagicMock()
    m.reconcile.side_effect = lambda lst, tenant_id=None: lst
    return m


@pytest.fixture
def mock_user_context():
    ctx = MagicMock()
    ctx.tenant_id = 102
    ctx.user_id = 7
    return ctx


@pytest.fixture
def app_service(mock_reconciliation):
    """Mirror A2 既有 fixture：所有 domain service 都 mock，只關心 reconciliation 被呼叫"""
    return SspExcelImportAppService(
        parse_job_domain_service=MagicMock(),
        parser=MagicMock(),
        file_upload_service=MagicMock(),
        module_frame_domain_service=MagicMock(),
        profile_service=MagicMock(),
        module_frame_service=MagicMock(),
        party_domain_service=MagicMock(),
        responsible_party_domain_service=MagicMock(),
        oscal_framework_version_domain_service=MagicMock(),
        catalog_control_domain_service=MagicMock(),
        party_reconciliation_service=mock_reconciliation,
        # ... 其他 dep 依 A2 既有 signature 補
    )
```

- [ ] **Step 2**: 補 4 個 integration case

```python
def test_superset_flow_calls_reconcile_once(app_service, mock_reconciliation, mock_user_context, patch_session_scope):
    """_confirm_superset_flow 必須在 write_parties 前 call reconcile(...) 一次"""
    job = MagicMock(uid="parse-uid", source_type="framework_version")
    parsed = MagicMock(parties_org=[MagicMock(party_type="organization")],
                       parties_person=[MagicMock(party_type="person")])
    app_service._confirm_superset_flow(job, parsed, decisions=[], overrides={}, user_context=mock_user_context)
    assert mock_reconciliation.reconcile.call_count == 1


def test_update_flow_calls_reconcile_once(app_service, mock_reconciliation, mock_user_context, patch_session_scope):
    """同上 — update flow"""
    job = MagicMock(uid="parse-uid", source_type="module_frame")
    parsed = MagicMock(parties_org=[], parties_person=[MagicMock(party_type="person")])
    app_service._confirm_update_flow(job, parsed, decisions=[], overrides={}, user_context=mock_user_context)
    assert mock_reconciliation.reconcile.call_count == 1


def test_reconcile_gets_mixed_list(app_service, mock_reconciliation, mock_user_context, patch_session_scope):
    """reconcile 拿到 (parties_org + parties_person) 合併 list"""
    org_party = MagicMock(party_type="organization", name="ACME")
    person_party = MagicMock(party_type="person", email_address="a@x.com")
    job = MagicMock(source_type="framework_version")
    parsed = MagicMock(parties_org=[org_party], parties_person=[person_party])
    app_service._confirm_superset_flow(job, parsed, decisions=[], overrides={}, user_context=mock_user_context)
    args, _ = mock_reconciliation.reconcile.call_args
    assert org_party in args[0]
    assert person_party in args[0]


def test_tenant_id_propagated_from_user_context(app_service, mock_reconciliation, mock_user_context, patch_session_scope):
    """tenant_id 從 user_context 傳到 reconcile"""
    job = MagicMock(source_type="framework_version")
    parsed = MagicMock(parties_org=[], parties_person=[])
    app_service._confirm_superset_flow(job, parsed, decisions=[], overrides={}, user_context=mock_user_context)
    _, kwargs = mock_reconciliation.reconcile.call_args
    assert kwargs.get("tenant_id") == 102
```

> **若 既有 conftest.py 已有 `patch_session_scope` helper**：直接 import 用，刪除上方 fixture 重複定義。grep `grep -n "patch_session_scope\|def session_scope" tests/conftest.py` 確認。

### 3.5 Commit T3

```bash
git add app/oscal/service/ssp_excel_import_app_service.py \
        tests/test_a3_reconciliation_a2_integration.py

git commit -m "$(cat <<'EOF'
feat(oscal): A3 T3 wire A2 confirm flows to reconciliation

_confirm_superset_flow + _confirm_update_flow 在 write_parties 前補
reconcile() 呼叫；A2 path 終於跟 docx path 行為一致。

A3 期間 fuzzy 在 A2 path **自動寫入**（行為等同 matched）；A5 phase
補 user 拍板路徑（design-A3.md §11 follow-up F2）。

對齊 design-A3.md §6.1 / §6.2。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

### Task 3 Acceptance

- [ ] `_confirm_superset_flow` + `_confirm_update_flow` 在 write_parties 前呼叫 `self._reconciliation.reconcile(...)`
- [ ] reconcile 拿到 mixed list（org + person 合併）
- [ ] tenant_id 從 user_context 正確傳入
- [ ] 既有 A2 99 unit test + 新 4 個 integration test 全綠
- [ ] BE 重啟（必 `lsof -ti:8000 | xargs kill -9 && nohup ...`）+ 提醒 user

---

## Task 4 — Fuzzy 演算法完整落地

> **目標**：把 T1.5 / T1.6 留下的 `_try_normalized_match` + `_try_fuzzy_match` stub 用 design-A3 §5.1 / §5.2 描述的演算法填滿。
> **TDD**：每個 stage 先寫 test 確認 stub 路徑會 fail（exact 沒中時走進來），再實作。

### 4.1 PersonReconciler normalized + fuzzy stages

**Files:**
- Modify: `domain/oscal/service/reconciliation/person_reconciler.py`
- Modify: `tests/test_a3_reconciliation_person.py`（補 normalized + fuzzy 兩段 ~8 case）

- [ ] **Step 1**: 補 failing test（normalized）

```python
class TestNormalizedStage:
    def test_plus_alias_normalize_hit(self, reconciler, user_domain):
        # input email: alice+work@acme.com
        # system: alice@acme.com
        def get_users(q):
            if getattr(q, "email", None) == "alice+work@acme.com":
                return []
            if getattr(q, "email", None) == "alice@acme.com":
                return [MagicMock(id=42, is_active=True, email="alice@acme.com")]
            return []
        user_domain.get_users.side_effect = get_users
        p = _parsed(email="alice+work@acme.com")
        reconciler.reconcile([p], tenant_id=102)
        assert p.match_method == MatchMethod.NORMALIZED
        assert p.matched_user_id == 42

    def test_no_plus_skips_normalized(self, reconciler, user_domain):
        # normalized = original → 跳過 normalized stage 直入 fuzzy
        ...
```

- [ ] **Step 2**: 補 failing test（fuzzy email domain + nickname/login_name 對齊）

```python
class TestFuzzyStage:
    def test_fuzzy_email_domain_with_nickname_match(self, reconciler, user_domain):
        # input: name='陳小明', email='unknown@acme.com'（exact / normalized 都不中）
        # candidate: User(email='alice@acme.com', nickname='陳小明', login_name='alice')
        def get_users(q):
            if getattr(q, "email", None):
                return []
            # 第三 stage 拉全 tenant list
            return [
                MagicMock(id=99, is_active=True, email="alice@acme.com",
                          nickname="陳小明", login_name="alice"),
            ]
        user_domain.get_users.side_effect = get_users
        p = _parsed(name="陳小明", email="unknown@acme.com")
        reconciler.reconcile([p], tenant_id=102)
        assert p.match_method == MatchMethod.FUZZY_EMAIL_DOMAIN
        assert p.match_confidence == 0.7
        assert p.matched_user_id == 99

    def test_fuzzy_no_email_skips(self, reconciler):
        p = _parsed(name="陳小明", email=None)
        reconciler.reconcile([p], tenant_id=102)
        assert p.match_method == MatchMethod.UNMATCHED

    def test_fuzzy_no_at_skips(self, reconciler):
        p = _parsed(name="陳小明", email="invalid")
        reconciler.reconcile([p], tenant_id=102)
        assert p.match_method == MatchMethod.UNMATCHED

    def test_fuzzy_no_candidates_in_domain(self, reconciler, user_domain):
        user_domain.get_users.return_value = []
        p = _parsed()
        reconciler.reconcile([p], tenant_id=102)
        assert p.match_method == MatchMethod.UNMATCHED
```

- [ ] **Step 3**: 跑 test 確認 FAIL（stub 回 None）

- [ ] **Step 4**: 實作（對齊 design-A3 §5.1）

```python
# domain/oscal/service/reconciliation/person_reconciler.py
from domain.oscal.service.reconciliation._normalizers import _strip_plus_alias, _normalize_name


class PersonReconciler(BaseReconciliationService[ParsedParty, object]):
    # ... __init__ + _try_exact_match 沿用 T1.5

    def _try_normalized_match(self, parsed, tenant_id):
        email = (parsed.email_address or "").strip().lower()
        if not email or '@' not in email:
            return None
        normalized = _strip_plus_alias(email)
        if normalized == email:
            return None
        try:
            from jedi_auth.domain.entity.user_query_entity import UserQueryEntity
            users = self._user.get_users(UserQueryEntity(email=normalized))
        except Exception:
            return None
        for u in users or []:
            if getattr(u, "is_active", True):
                return u
        return None

    def _try_fuzzy_match(self, parsed, tenant_id):
        email = (parsed.email_address or "").strip().lower()
        if not email or '@' not in email:
            return (None, MatchMethod.UNMATCHED)
        domain = email.split('@', 1)[1]
        if not domain:
            return (None, MatchMethod.UNMATCHED)

        def _loader():
            from jedi_auth.domain.entity.user_query_entity import UserQueryEntity
            return self._user.get_users(UserQueryEntity())

        candidates = self._get_or_load_candidates(_loader)
        parsed_name = _normalize_name(parsed.name)
        for u in candidates:
            if not getattr(u, "is_active", True):
                continue
            u_email = getattr(u, "email", None)
            if not u_email or '@' not in u_email:
                continue
            if u_email.split('@', 1)[1].lower() != domain:
                continue
            u_nickname = _normalize_name(getattr(u, "nickname", None))
            u_login = _normalize_name(getattr(u, "login_name", None))
            if parsed_name and (u_nickname == parsed_name or u_login == parsed_name):
                return (u, MatchMethod.FUZZY_EMAIL_DOMAIN)
        return (None, MatchMethod.UNMATCHED)
```

- [ ] **Step 5**: 跑 test 全綠

### 4.2 OrganizationReconciler normalized + fuzzy stages

**Files:**
- Modify: `domain/oscal/service/reconciliation/organization_reconciler.py`
- Modify: `tests/test_a3_reconciliation_organization.py`（補 ~5 case）

- [ ] **Step 1-2**: 補 normalized + fuzzy failing test

```python
class TestNormalizedStage:
    def test_full_width_space_normalize(self, reconciler, org_unit_domain):
        # input: "ACME　Inc"（全形）；system: "ACME Inc"（半形）
        ...

class TestFuzzyStage:
    def test_strip_股份有限公司_match(self, reconciler, org_unit_domain):
        # input: "ACME 股份有限公司"；system: "ACME"
        def get_org_units(q):
            if getattr(q, "name", None):
                return []
            return [MagicMock(id=7, name="ACME")]
        org_unit_domain.get_org_units.side_effect = get_org_units
        p = _parsed(name="ACME 股份有限公司")
        reconciler.reconcile([p], tenant_id=102)
        assert p.match_method == MatchMethod.FUZZY_NAME_PREFIX
        assert p.matched_org_unit_id == 7

    def test_no_suffix_skips_fuzzy(self, reconciler):
        p = _parsed(name="ACME")
        # normalized == name，不會 strip 出新 stripped → fuzzy 直接 UNMATCHED
        reconciler.reconcile([p], tenant_id=102)
        assert p.match_method == MatchMethod.UNMATCHED
```

- [ ] **Step 3-4**: 實作 + 全綠（對齊 design-A3 §5.2）

```python
from domain.oscal.service.reconciliation._normalizers import _normalize_name, _strip_org_suffix


class OrganizationReconciler(...):
    # ... __init__ + _try_exact_match 沿用 T1.6

    def _try_normalized_match(self, parsed, tenant_id):
        name = (parsed.name or "").strip()
        if not name:
            return None
        normalized = _normalize_name(name)
        if normalized == name:
            return None
        try:
            from jedi_auth.domain.entity.org_unit_query_entity import OrgUnitQueryEntity
            q = OrgUnitQueryEntity(name=normalized)
            if tenant_id is not None:
                q.tenant_id = tenant_id
            orgs = self._org.get_org_units(q)
        except Exception:
            return None
        return orgs[0] if orgs else None

    def _try_fuzzy_match(self, parsed, tenant_id):
        name = (parsed.name or "").strip()
        if not name:
            return (None, MatchMethod.UNMATCHED)
        normalized = _normalize_name(name)
        stripped = _strip_org_suffix(normalized)
        if stripped == normalized:
            return (None, MatchMethod.UNMATCHED)

        def _loader():
            from jedi_auth.domain.entity.org_unit_query_entity import OrgUnitQueryEntity
            q = OrgUnitQueryEntity()
            if tenant_id is not None:
                q.tenant_id = tenant_id
            return self._org.get_org_units(q)

        candidates = self._get_or_load_candidates(_loader)
        for o in candidates:
            o_name = _normalize_name(getattr(o, "name", None))
            if _strip_org_suffix(o_name) == stripped:
                return (o, MatchMethod.FUZZY_NAME_PREFIX)
        return (None, MatchMethod.UNMATCHED)
```

### 4.3 Commit T4

```bash
git add domain/oscal/service/reconciliation/person_reconciler.py \
        domain/oscal/service/reconciliation/organization_reconciler.py \
        tests/test_a3_reconciliation_person.py \
        tests/test_a3_reconciliation_organization.py

git commit -m "$(cat <<'EOF'
feat(oscal): A3 T4 fuzzy match algorithm

PersonReconciler 補 normalized stage（+alias 移除）+ fuzzy stage
（email domain + nickname/login_name 對齊）；
OrganizationReconciler 補 normalized stage（全形半形 / collapse）+
fuzzy stage（剔除尾綴）。

Client-side filter — 拉全 tenant list cache 在 reconciler instance
lifetime；exception 不寫入 cache（next call retry）。
ParsedParty 帶 match_method + match_confidence。

對齊 design-A3.md §5.1 / §5.2 / §5.4。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

### Task 4 Acceptance

- [ ] PersonReconciler normalized stage 通過 alias / 無 + 跳過 / domain DB exception 三 case
- [ ] PersonReconciler fuzzy stage 通過 email domain + nickname match / login_name match / no_email / no_at / no_candidates 五 case
- [ ] OrganizationReconciler normalized + fuzzy 全綠
- [ ] 累計 PersonReconciler ~12 + OrganizationReconciler ~10 test 全綠
- [ ] candidate cache 同 instance lifetime 只 load 一次（reuse base.py test）

---

## Task 5 — Unit Test + Integration Test 補齊

> **目標**：累計到 ~51 個 A3 新 test 全綠（design-A3 §8 原寫 ~38 漏算 13 normalizers helper test；以 ~51 為準）+ 既有 213 test 不破壞 → 累計 ~264 BE test 全綠。
> **本 task 是補洞**：把 T1~T4 散佈的 test 補齊 corner case 並加 matrix gap test。

### 5.1 補 Person / Organization 額外邊界 case

**Files:**
- Modify: `tests/test_a3_reconciliation_person.py`
- Modify: `tests/test_a3_reconciliation_organization.py`

- [ ] **Step 1**: Person matrix gap

| Case | 預期 |
|------|------|
| exact / normalized / fuzzy 三階段都拉到 active=False → UNMATCHED | UNMATCHED |
| fuzzy stage candidate cache exception → 整批 UNMATCHED 不抛 | UNMATCHED |
| email 「a@」域名空 → 直入 UNMATCHED 不抛 | UNMATCHED |
| email 「@b.com」local 空 + normalized 跳過 + fuzzy 域名比對到 | NORMALIZED 或 UNMATCHED（看 _strip_plus_alias 行為）|

- [ ] **Step 2**: Organization matrix gap

| Case | 預期 |
|------|------|
| name 「公司」單獨 → stripped = "" → UNMATCHED | UNMATCHED |
| 多層 suffix（「ACME 股份有限公司 Inc.」）只 strip 第一個 trailing | strip 「Inc.」一次 |
| 全空白 name → UNMATCHED | UNMATCHED |

### 5.2 ParsedParty fixture test

**Files:**
- Create: `tests/test_a3_parsed_party_fields.py`

- [ ] **Step 1**: 4 個 case 驗 ParsedParty default / 顯式 set / `asdict()` 序列化值 / JSONB 反序列化字串對齊

```python
from dataclasses import asdict
import json
from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.reconciliation.match_method import MatchMethod


def test_default_match_method_unmatched():
    p = ParsedParty(name="A", party_type="person", role=None, email_address=None)
    assert p.match_method == MatchMethod.UNMATCHED
    assert p.match_confidence == 0.0

def test_set_match_method_explicit():
    p = ParsedParty(name="A", party_type="person", role=None, email_address=None,
                    match_method=MatchMethod.FUZZY_EMAIL_DOMAIN, match_confidence=0.7)
    assert p.match_method == "fuzzy_email_domain"  # StrEnum value 比較

def test_asdict_serializes_match_method_as_string_value():
    """StrEnum 在 dataclasses.asdict() 出來值是 enum instance；JSON dump 後是字串值。
    A5 預覽 UI 反序列化拿到的必須是 "unmatched" 不是 "MatchMethod.UNMATCHED"。
    """
    p = ParsedParty(name="A", party_type="person", role=None, email_address=None)
    d = asdict(p)
    # asdict 行為：StrEnum 仍是 enum instance（不是字串）
    assert d["match_method"] == MatchMethod.UNMATCHED
    # JSON dump 過後變字串 — JSONB 寫入 → A5 UI 反序列化拿到的是這個
    json_str = json.dumps(d, default=str)
    parsed_back = json.loads(json_str)
    assert parsed_back["match_method"] == "unmatched"
    assert parsed_back["match_confidence"] == 0.0

def test_fuzzy_method_jsonb_value():
    """同上 — FUZZY_EMAIL_DOMAIN 序列化後是 "fuzzy_email_domain" 字串"""
    p = ParsedParty(name="A", party_type="person", role=None, email_address=None,
                    match_method=MatchMethod.FUZZY_EMAIL_DOMAIN, match_confidence=0.7)
    d = asdict(p)
    json_str = json.dumps(d, default=str)
    parsed_back = json.loads(json_str)
    assert parsed_back["match_method"] == "fuzzy_email_domain"
    assert parsed_back["match_confidence"] == 0.7
```

> **若 parse_jobs 寫入路徑用的不是 `asdict + json.dumps`** 而是自訂 serializer：T3 開工瞬間 grep `parsed_result\s*=` 在 `app/oscal/service/ssp_excel_import_app_service.py` 找實際序列化點，補對應 assert（保住 A5 UI 反序列化拿到 string value 不是 `"MatchMethod.UNMATCHED"`）。

### 5.3 既有 213 BE test 跑全綠驗證

- [ ] **Step 1**: 跑全套確認

```bash
pytest tests/ -v --ignore=tests/legacy 2>&1 | tail -50
```

**Expected**：~264 test passed（A1 106 + A2 99 + party 8 + A3 新 ~51）。

**A3 新 ~51 case 拆解**：
- 13 normalizers helper (T1.2 — `_strip_plus_alias` / `_normalize_name` / `_strip_org_suffix`)
- 7 base 抽象基類 (T1.3 — 三階段 fallback + cache 行為)
- 12 PersonReconciler (T1.5 exact 4 + T4.1 normalized + fuzzy 8)
- 10 OrganizationReconciler (T1.6 exact 5 + T4.2 normalized + fuzzy 5)
- 3 facade dispatch (T2.3)
- 4 A2 integration (T3.4)
- 4 ParsedParty fixture (T5.2 — default / 顯式 set / asdict serialize / fuzzy JSONB value)
- 2 matrix gap (T5.1 — Person + Organization 各 1~2 邊界 case)

> **與 design-A3 §8 acceptance criterion 第 7 條對齊**：design 原文寫「~38」**漏算了 13 normalizers helper test**；T7.3 補 design §11 reconciliation 一條：「A3 ship 後實際 ~51 test，design §8 ~38 數字僅算 reconciliation 主數，未含 normalizers helper」。
>
> **若 pre-existing test fail**（A0.1 已知 `test_write_parties_refreshes_existing_party_in_place`）：標 follow-up 不擋 ship。

### 5.4 Commit T5

```bash
git add tests/test_a3_reconciliation_person.py \
        tests/test_a3_reconciliation_organization.py \
        tests/test_a3_parsed_party_fields.py

git commit -m "$(cat <<'EOF'
test(oscal): A3 T5 unit + integration tests

補齊 PersonReconciler / OrganizationReconciler 邊界 case；
新增 ParsedParty 加欄位 + asdict JSONB serialize 的 4 個 fixture test；
累計 A3 新增 ~51 unit + integration test 全綠（13 normalizers + 7 base
+ 12 person + 10 org + 3 facade + 4 A2 integration + 4 ParsedParty
+ ~2 matrix gap）；
既有 213 test 不破壞 → 累計 ~264 BE test 全綠。

對齊 design-A3.md §8 acceptance criteria（原寫 ~38 漏算 normalizers helper
test，§11 reconciliation 補一條說明）。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

### Task 5 Acceptance

- [ ] A3 新 test 共 ~51 個全綠（13 normalizers + 7 base + 12 person + 10 org + 3 facade + 4 A2 integration + 4 ParsedParty + ~2 matrix gap）
- [ ] 既有 213 BE test 全綠（A1 106 + A2 99 + party 8）
- [ ] 累計 ~264 test passed（pre-existing 1 個 known fail 列 follow-up）
- [ ] T7.3 design §11 reconciliation 必補一條「test 數實際 ~51（含 13 normalizers），design §8 原寫 ~38 漏算 helper」

---

## Task 6 — Cucumber Regression（compliance-manager-test repo）

> **目標**：design-A3 §6.5 描述的 5 scenarios — 4 exact/normalized + 1 fuzzy。
> **跨 repo 注意事項**：本 task 在 `~/Projects/Billows/Audit-Manager/compliance-manager-test/` 進行，**branch / commit 跟 BE 主 repo 分開**；BE feature branch 與 test repo branch 不耦合。
> **Partial ship 允許**：若 user 端 GitLab env 未配齊（A0.1 follow-up #7）→ scenarios 撰寫完成可接受，跑不過列 follow-up T8；不擋 A3 ship。

### 6.1 切到 test repo（先確認 BE working tree 乾淨再切）

- [ ] **Step 1**: BE 主 repo working tree clean 安全閥

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
git status --short
```

**必須是空 output 或只有 pre-existing pyproject.toml dev-path 改動**。若有 unstaged 改動 → 先完成 BE 端 T0~T5 任一 commit 再切。

- [ ] **Step 2**: 切過去

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
git status
git checkout -b feature/a3-ssp-party-match-regression  # 或沿用既有 phase 2 branch
```

### 6.2 寫 feature file

**Files:**
- Create: `features/regression/module-frame/05-ssp-docx-import-party-match.feature`

```gherkin
# language: zh-TW
功能: SSP Docx 匯入 — Party Match Regression（A3）

  作為合規顧問
  我希望 docx 匯入時 party 自動跟系統 user / org_unit 鉤稽
  以便建檔時不用手動對齊每個 OSCAL party

  背景:
    假設 我以「合規管理員」身份登入
    並且 我已開啟一個既有的合規資源庫

  場景: docx 含 person email 完全相符 system user
    假設 docx 內含 person email "alice@acme.com" 且系統 user 已有此 email
    當 我上傳 docx 並等解析完成
    那麼 API response parties[].matched_user_id 應該填入該 user id
    並且 parties[].match_method 應為 "exact"

  場景: docx 含 organization name 完全相符 system org
    假設 docx 內含 organization "ACME" 且系統 org_unit 已有此名稱
    當 我上傳 docx 並等解析完成
    那麼 API response parties[].matched_org_unit_id 應該填入該 org id
    並且 parties[].match_method 應為 "exact"

  場景: docx 含 person email 完全不相符
    假設 docx 內含 person email "unknown@unknown.com"
    當 我上傳 docx 並等解析完成
    那麼 API response parties[].matched_user_id 應為 null
    並且 parties[].match_method 應為 "unmatched"

  場景: docx 含 person email +alias normalize 後相符（NORMALIZED）
    假設 docx 內含 person email "alice+work@acme.com" 且系統 user email 為 "alice@acme.com"
    當 我上傳 docx 並等解析完成
    那麼 API response parties[].matched_user_id 應該填入該 user id
    並且 parties[].match_method 應為 "normalized"

  場景: docx 含 organization name 帶尾綴 fuzzy 相符（FUZZY_NAME_PREFIX）
    假設 docx 內含 organization "ACME 股份有限公司" 且系統 org_unit 名稱為 "ACME"
    當 我上傳 docx 並等解析完成
    那麼 API response parties[].matched_org_unit_id 應該填入該 org id
    並且 parties[].match_method 應為 "fuzzy_name_prefix"
```

### 6.3 寫對應 steps + page object

**Files:**
- Create: `features/regression/module-frame/steps/ssp-docx-import-party-match.steps.js`（或合進既有 steps）
- Modify: `features/regression/module-frame/pages/ssp-docx-import-page.js`（加 selector / API response assertion helper）

> **參考既有 docx import scenarios** — 對齊既有 step 體例；assertion 用 API response 而非 UI dom（regression 偏 BE 行為）。

### 6.4 Smoke run（user GitLab env 配齊時才能跑）

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
npm run cucumber -- features/regression/module-frame/05-ssp-docx-import-party-match.feature
```

**Expected**：5 scenarios 全綠。

### 6.5 Commit T6

> **在 test repo commit**：

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
git add features/regression/module-frame/05-ssp-docx-import-party-match.feature \
        features/regression/module-frame/steps/ssp-docx-import-party-match.steps.js

git commit -m "$(cat <<'EOF'
test(compliance-manager-test): A3 T6 cucumber party match regression

新增 5 scenarios cover docx import party match（4 exact/normalized + 1 fuzzy）；
A3 ship 後 docx import party match regression baseline = 這 5 個 scenarios。

對齊 compliance-manager-be design-A3.md §6.5。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

### Task 6 Acceptance

- [ ] feature file 含 5 scenarios（4 exact/normalized + 1 fuzzy）
- [ ] steps + page object 串到既有 docx import infra
- [ ] **Partial ship allowed**：若 env 未配齊跑不過，scenarios 撰寫完成標 follow-up T8 即可

---

## Task 7 — Changelog + Tracker + Design §11 + Final SUMMARY

### 7.1 寫 Changelog

**Files:**
- Create: `docs/changelog/2026-05-XX-feat-party-matcher-shared.md`（XX = ship 當日）

```markdown
---
type: feat
breaking: false
modules: [oscal, ssp-import]
commit: <T1 + T2 + T3 + T4 commit hash chain>
---

# feat: 共用 matcher 抽取 — parties / org-units (A3)

## 需求說明

A2 ship 後，docx + Excel 兩條 SSP 匯入 path 都需要 ParsedParty → 系統
user / org_unit 鉤稽；A2 phase 跳過串接、行為跟 docx 不一致。A3 抽出共用
`BaseReconciliationService` 並補上 A2 串接、引入 fuzzy 中間狀態。

## 變更範圍

### 新建檔案

- domain/oscal/service/reconciliation/ sub-folder（5 個檔 + 1 個 helper）
- tests/test_a3_reconciliation_*.py + tests/test_a3_parsed_party_fields.py（7 個檔，共 ~51 個 case）

### 改動既有檔案

- domain/oscal/service/party_reconciliation_service.py — 改門面（公開 method 簽章不變）
- domain/oscal/parser/ssp_intermediate.py — ParsedParty 加 match_method + match_confidence
- app/oscal/service/ssp_excel_import_app_service.py — _confirm_*_flow 補 reconcile() 呼叫
- di_containers/oscal/oscal_containers.py — 加 person_reconciler / organization_reconciler Factory

### 跨 repo

- compliance-manager-test — 新增 cucumber regression `05-ssp-docx-import-party-match.feature` 含 5 scenarios

## 行為差異

| Caller | A3 前 | A3 後 |
|--------|-------|-------|
| docx flow parse-time | exact match only | exact + normalized + fuzzy 三階段 |
| docx flow confirm-time | 同上 | 同上 |
| A2 superset flow | **不呼叫** reconcile | 呼叫 reconcile，fuzzy 自動寫入 |
| A2 update flow | **不呼叫** reconcile | 呼叫 reconcile，fuzzy 自動寫入 |

## 測試結果

- A3 新增 ~51 unit + integration test 全綠（13 normalizers + 7 base + 12 person + 10 org + 3 facade + 4 A2 integration + 4 ParsedParty + ~2 matrix gap）
- 既有 213 BE test（A1 106 + A2 99 + party 8）全綠
- 累計 ~264 BE test passed
- Cucumber regression：5 scenarios 撰寫完成（**partial ship**：env 配齊後才能跑）

## A3 ship 後 regression baseline

任何後續 phase 動到 PartyReconciliationService / PersonReconciler /
OrganizationReconciler / fuzzy 演算法時，cucumber 5 scenarios 必過 + 既有 8
party unit test 必過。

## Follow-up

- F1：docx parser role normalize map（issue 修補 B）— A3 後獨立 commit
- F2：A5 phase 補 A2 fuzzy 的 user 拍板路徑
- F4：jedi-auth Query Entity 加 _in_email_domain（prod 大 tenant 才考慮）
- T8（cucumber partial ship）：env 配齊後跑 5 scenarios

## 參考

- docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A3.md（13 章）
- docs/features/FR-011.2-2605-ssp-import-export-phase2/implementation-plan-A3.md
```

### 7.2 更新 tracker

**Files:**
- Modify: `docs/features/FR-011.2-2605-ssp-import-export-phase2/README.md`

- [ ] A3 row：`pending` → `BE shipped ✅`
- [ ] 補 commit chain（T1+T2+T3+T4+T5+T7 BE hash + T6 test repo hash）

### 7.3 補 design-A3.md §11 Implementation Reality

**Files:**
- Modify: `docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A3.md`（§11 段）

> **必出條目**（無論實作有無偏差都要寫）：
> - **Test count**：「A3 ship 後實際 ~51 test（含 13 normalizers helper），design §8 第 7 條原寫 ~38 漏算 helper test；以 ~51 為準」
> - **base.py exception swallow 差異**：「`_get_or_load_candidates` exception 路徑走 `return []` 完全靜默（no log），與 docx parse-time line 264 `logger.warning` swallow 行為不一致；base 內部 cache exception 不污染 caller log，屬可接受差異」
>
> **可選條目**（逐條列出實作偏差跟原因 — 沿用 SSP Update Diff §11 reconciliation pattern 16 條 deviations 樣板）。
> 例：
> - 「§5.1 fuzzy 演算法原 design 用 `for u in candidates: continue` 結構；實作改成 `if u.email is None: continue` 提前過濾，行為相同但 readability 較好」
> - 「§4.2 ParsedParty.to_dict 原假設已存在；實作時發現需另用 `asdict()` from `dataclasses`，補 ParsedParty fixture test」
> - 「§6.2 A2 confirm flow 串接點原假設是 dict list；實作 verify 後是 ParsedParty list，串接點不變但 reconcile() 拿到 dataclass list 直接 mutate」
> - ... （視實際偏差填）
>
> **若實作完全對齊 design 無其他偏差**：在「必出條目」之後寫一條「除上述兩條外，A3 實作完全對齊 design-A3，無其他偏差條目」即可結束 §11。**不能空段不寫**。

### 7.4 寫 Final SUMMARY

**Files:**
- Create: `docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/2026-05-XX-a3-SUMMARY.md`

> **不是只給簡短摘要** — 按 CLAUDE.md「做 summary」段，完整收尾報告，含：
> 1. A3 task arc commit 清單（BE + test repo）
> 2. 改動範圍（檔案清單）
> 3. 行為差異（A3 前 vs A3 後）
> 4. 規範文件齊全度（changelog / analysis / issue 各檔指標）
> 5. 已知 follow-up（F1-F7 + T8）
> 6. 部署 handover（jedi-* 仍 path-dep / pyproject.toml dev-path 不 commit / 須提醒 user BE 重啟）

### 7.5 對話歷史 dump（Session E task arc 收口）

- [ ] 把整段 A3 對話原文 dump 到 `docs/conversation-history/2026-05-XX/ssp-import-export-phase2/`
- [ ] 命名：`part-NN-of-NN-<sub-topic>.md`（按 phase / 重大決策點為界）
- [ ] **不做格式化** — 使用者匯出的原始對話直接寫入（CLAUDE.md 規範）
- [ ] handoff prompt / SUMMARY 已在 `docs/features/<feature>/handoff/`，**不混進 conversation-history**

### 7.6 Commit T7（BE main repo）

```bash
git add docs/changelog/2026-05-XX-feat-party-matcher-shared.md \
        docs/features/FR-011.2-2605-ssp-import-export-phase2/README.md \
        docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A3.md \
        docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/2026-05-XX-a3-SUMMARY.md \
        docs/conversation-history/2026-05-XX/ssp-import-export-phase2/

git commit -m "$(cat <<'EOF'
docs(ssp-import-export-phase2): A3 收尾 + SUMMARY

- changelog feat-party-matcher-shared.md
- tracker README A3 row → BE shipped + commit chain
- design-A3.md §11 implementation reality reconciliation
- handoff A3 SUMMARY（task arc 收口）
- conversation-history dump（A3 task arc 全對話原文）

A3 phase 完整收口；A4 phase 可在 BaseReconciliationService 上擴
device / info_system / leveraged / catalog control / AO 5 種新 reconciler。

對齊 design-A3.md §8 + §12 收口規範 / CLAUDE.md 做 summary 段。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

### Task 7 Acceptance

- [ ] Changelog frontmatter + body 完整（含 commit hash chain）
- [ ] tracker README A3 row 標 BE shipped
- [ ] design-A3.md §11 至少含 2 條「必出條目」（test count 51 vs 38 + base exception swallow 差異），其他偏差視實作有無補；**§11 不能空段**
- [ ] SUMMARY 含 6 段（commits / 改動範圍 / 行為差異 / 規範文件 / follow-up / 部署 handover）
- [ ] 對話歷史 dump 完成（兩層目錄 `<date>/ssp-import-export-phase2/`）
- [ ] BE working tree 乾淨（除 pre-existing pyproject.toml dev-path）

---

## 規範遵守清單（執行時逐項勾）

- [ ] 顯式 `git add <file>`，禁 `-am` / `-A`（subagent dispatch prompt 也要明寫）
- [ ] Commit 含 `Co-Authored-By: Claude Opus 4.7 (1M context)` footer
- [ ] jedi-* 不動（design-A3 §7 verify #3 / §10 已標）
- [ ] pyproject.toml dev-path 改動不 commit
- [ ] DDD 嚴格分層 — Route 不碰 DB / App Service `@transaction` / Reconciler 純 domain 函式不接 session
- [ ] BE 重啟必 `lsof -ti:8000 | xargs kill -9 && nohup poetry run python main_app.py > /dev/null 2>&1 &`
- [ ] 改 BE service code（T2 / T3 / T4）後**必提醒 user 重啟** — BE 沒 hot reload
- [ ] 不寫 docstring / 註解除非真有 non-obvious 的 why
  - **A3 例外**：`BaseReconciliationService` 4 hook + `_get_or_load_candidates` exception 行為兩處屬「non-obvious why」，允許寫 docstring
- [ ] Test 用 mock 包 logger 避開 jedi DBLogHandler 撞 SessionLocal=None；`@transaction` 用 `patch_session_scope` 接 `contextlib.contextmanager`
- [ ] T0 verify 結果不符 design → 主動修 design-A3 §11 reconciliation，不照舊硬幹
- [ ] 跨 repo 切換時提醒 user：cucumber 在 compliance-manager-test repo，commit / branch 分開
- [ ] 改 docx parser 既有行為 → cucumber regression 必跑 — 是 ship 必要條件（**partial ship 允許**：env 未配齊時 scenarios 撰寫完成即可）
- [ ] 重大決策 → `docs/analysis/2026-05-XX-<topic>.md`（trade-off 取捨理由 — 若 implementer 出現意外取捨）
- [ ] 重型 bug / 跨域問題 → `docs/issues/pending/2026-05-XX-<title>.md`（不擠在 changelog）
- [ ] Handoff 文件統一放 `docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/`（CLAUDE.md 2026-05-20 新規）
- [ ] 階段性 commit 不用問（每 Task 完成 commit 直接做）
- [ ] Subagent dispatch 必加「git add 顯式檔名，禁用 `-am`」（避免 sweep CLAUDE.md 或不相關 modification）

---

## 風險檢核點

| Task | 檢核點 | 處理 |
|------|--------|------|
| T0 | T0.2 verify 發現 A2 confirm flow 已被別人補上 reconcile | T3 task 改標 N/A，跳過直入 T4 |
| T0 | T0.3 verify 發現 ParsedParty 已被加 match_method 但命名不同 | 停下對齊命名 + 主動修 design §11 |
| T1 | base.py 7 個 test 跑不過：抽象 method 漏實作 / Generic 命名衝突 | check `from abc import ABC, abstractmethod` import + Generic[TParsed, TEntity] 順序 |
| T2 | 既有 8 party test fail：mock 注入結構改變 | 看是否用了 service 內部 private method；如果有就把 mock 注入點改成兩 reconciler |
| T2 | DI wire error：auth_container.user_domain_service 跨 container 引用失敗 | 對齊 既有 line 490/491 pattern；確認 sub-container reference 拿到 |
| T3 | A2 confirm flow `_write_parties` 接受 dict list 不是 ParsedParty list | reconcile() 在轉 dict 之前對 ParsedParty list 跑（design §11 reconciliation 補一條） |
| T3 | A2 既有 99 unit test fail：mock 注入沒包 reconciliation | 看是否在既有 fixture 中 mock 注入 party_reconciliation_service 沒提供 .reconcile method |
| T4 | candidate cache 在 jedi-auth `UserQueryEntity()` 空 query 拉不到全 list（RLS) | verify `UserQueryEntity()` 不帶條件時 RLS 自動隔離 + 拿到全 tenant active list |
| T4 | Fuzzy name match false positive 過多 | unit test 加更多邊界 case；prod feedback 後 follow-up F5 擴 ORG_SUFFIXES |
| T5 | 累計 test count < ~51：corner case 漏 | 看 design-A3 §8 acceptance criteria 第 7 條 + plan T5.3 的 51 拆解，逐項對齊 |
| T5 | `asdict()` 序列化 MatchMethod StrEnum 給 A5 UI 拿到 `"MatchMethod.UNMATCHED"` 而非 `"unmatched"` | T5.2 test 已固化 `json.dumps(d, default=str)` 行為 — implementer 必須 verify parse_jobs 寫入路徑也走 `json.dumps + default=str` 或自訂 serializer 對齊 StrEnum value 行為 |
| T6 | env 未配齊跑不過 | 列 follow-up T8，scenarios 撰寫完成 partial ship |
| T7 | §11 reconciliation 段沒實作偏差可寫 → 段落空 | 標「§11 無偏差」即可，沿用 SSP Update Diff §11 16 條樣板的「無偏差」標示 |

---

## 跨 repo 工作清單

| Repo | 工作 | Branch | Commit 預估 |
|------|------|--------|-------------|
| compliance-manager-be（主）| T0 + T1 + T2 + T3 + T4 + T5 + T7 | `feature/ssp-import-export-phase2` | 6 commits |
| compliance-manager-test | T6 cucumber regression | `feature/a3-ssp-party-match-regression`（或既有 phase 2 branch）| 1 commit |
| compliance-manager-fe | **不動** | — | 0 commit |
| jedi-* 套件 | **不動** | — | 0 commit |

---

## 不在 A3 範圍（明確排除）

- ❌ A4 5 種新 reconciler（device / info_system / leveraged / catalog control / AO）— follow-up F3
- ❌ docx parser role normalize map（issue 修補 B）— follow-up F1
- ❌ A5 預覽 UI 對 A2 fuzzy 的 user 拍板路徑（A3 期間自動寫入）— follow-up F2
- ❌ jedi-auth Query Entity 加 `_in_email_domain` / `_in_name_prefix` — follow-up F4
- ❌ OSCAL party ↔ project_participant 雙寫設計（issue 修補 D）— follow-up F5
- ❌ Dev DB `oscal_responsible_parties.role_id` 髒資料 audit / 清理（issue 修補 C）— follow-up F6
- ❌ docx parser fuzzy 標記後預覽 UI 互動 — follow-up F7

---

## 換 Session 收尾規範（每 Session 結束時）

按 CLAUDE.md「做 summary 觸發完整收尾」段：

1. 盤點 commits + working tree 乾淨
2. 規範文件齊全度檢查（changelog / analysis / issue / design §11 reconciliation）
3. 產 handoff prompt → `docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/2026-05-XX-a3-<session 字母>-to-<下一 session>.md`
4. Session E 是 task arc 收口 → 對話歷史按日期 dump 到 `docs/conversation-history/<date>/ssp-import-export-phase2/`（**不做格式化**）

| 換 Session 時點 | 必產出 |
|----------------|--------|
| C → D | handoff prompt（T1 + T2 已 ship，T3 + T4 開工指引） |
| D → E | handoff prompt（T3 + T4 已 ship，T5 + T6 + T7 開工指引） |
| E（收口） | final SUMMARY + design §11 reconciliation + conversation-history dump |

---

## 工具預期

- TDD：每個 task 都先寫 failing test 再實作（superpowers:test-driven-development）
- 子 task 完成階段性 commit（不用停下問 user）
- T6 跨 repo 切換時切勿汙染 BE working tree
- T7 SUMMARY 階段：mirror SSP Update Diff `docs/conversation-history/2026-05-06-ssp-update-diff/SUMMARY.md` 的 6 段體例
- Subagent dispatch（如需 parallel test 補齊）：明寫「git add 顯式檔名 禁用 -am」+「對齊 既有 mock pattern」+「不動 pyproject.toml」三鐵律

---

**下一步**：本 plan 經 plan-document-reviewer subagent review approved + user review GO 後 → Session B 結束，commit plan-A3.md + 產 Session B → C handoff prompt → 換 Session C 開 T1。
