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

> **Phase**：A3（Track A：Excel 匯入第三階段）
> **級別**：重型（brainstorm → design → plan → 開工）
> **狀態**：design draft（brainstorm 收斂完成 2026-05-20）
> **前置**：A1（樣板下載）+ A2（Parser + 解析 API）皆 BE shipped
> **依賴本 phase**：A4（devices / info_systems / leveraged / controls / AOs 5 種新 matcher）建在本 phase BaseReconciliationService 抽象基類上

---

## 1. 為什麼要做 A3

### 1.1 既有現況

A2 ship 後，主專案有兩條 SSP 匯入入口都需要做「ParsedParty → 系統 user / org_unit」的鉤稽：

| 入口 | 既有 reconciliation 行為 |
|------|-------------------------|
| **Docx import**（`SspDocxImportAppService` — parse-time line 258-264 + confirm-time line 489-490 共 2 處）| 直接呼叫 `PartyReconciliationService.reconcile()`：person email exact / organization name exact 兩態 |
| **Excel import**（`SspExcelImportAppService`） | DI 已注入 `party_reconciliation_service`（line 50/59），但 `_confirm_superset_flow` (line 251) + `_confirm_update_flow` (line 325) **沒有呼叫** reconcile — A2 phase 跳過此串接 |

A4 後續還要加 5 種新 matcher（devices / info_systems / leveraged / catalog controls / AOs）— 若 A3 不抽共用層，A4 會踩「複製 5 份 reconcile batch 邏輯」的反 pattern。

### 1.2 A3 三個目標

1. **抽共用 base**：把 `PartyReconciliationService` 內部邏輯抽成 `BaseReconciliationService[TParsed, TEntity]` 抽象基類；A4 直接擴 hook
2. **引入 fuzzy 中間狀態**：原 `matched / unmatched` 二態升級 `matched / fuzzy / unmatched` 三態，cover「VLOOKUP 未重算」/「email 帶 +alias」/「組織名帶尾綴」常見 case
3. **串接 A2 path**：補上 A2 `_confirm_*_flow` 對 reconcile 的呼叫，讓 docx + A2 兩條 import flow 行為一致

### 1.3 與 issue 修補的關係

對應 issue：`docs/issues/pending/2026-05-19-person-role-cross-domain-inconsistency.md`

A3 範圍**不整併**修補 B（docx parser 加 role normalize map）— role 是 enum 規範問題、不是 matcher 問題；matcher 處理「字串 → 系統實體」，role enum 屬於另一域。修補 B 列 follow-up（F1）。

---

## 2. 範圍 / 不在範圍

### In Scope

- 新建 `domain/oscal/service/reconciliation/` sub-folder + 4 個檔（base / match_method / person_reconciler / organization_reconciler）
- 既有 `PartyReconciliationService` 改為門面（facade），dispatch by `party_type` 到兩個 reconciler；公開 method `reconcile(parties, tenant_id)` 簽章 100% 不變
- `ParsedParty` dataclass 加 2 個 optional 欄位（`match_method` + `match_confidence`），既有 caller / test fixture 不破壞
- DI container 新增兩個 reconciler Factory；門面改注入兩 reconciler
- A2 `_confirm_superset_flow` + `_confirm_update_flow` 補 reconcile() 呼叫
- Fuzzy 演算法：
  - Person：normalize 後 email exact / `+alias` 移除後 exact / email domain + nickname 對齊
  - Organization：normalize 後 name exact / 全形半形 / 剔除尾綴後 exact
- Candidate cache：每次 reconcile() call 在 reconciler instance 內 cache 全 tenant user / org_unit list（client-side filter）
- Cucumber regression `features/regression/module-frame/05-ssp-docx-import-party-match.feature` 含 5 scenarios（4 exact/normalized + 1 fuzzy）
- Changelog `docs/changelog/YYYY-MM-DD-feat-party-matcher-shared.md`（type: feat、modules: oscal）

### Out of Scope（列 follow-up）

| # | 不在 A3 的事 | 處理時機 |
|---|-------------|---------|
| F1 | Issue 修補 B（docx parser 加 role normalize map）| A3 後獨立 commit / 或 issue resolution 階段 |
| F2 | A5 預覽 UI 對 A2 fuzzy 的 user 拍板路徑（A3 期間 A2 fuzzy 自動寫入）| A5 phase |
| F3 | A4 加 5 個新 reconciler（device / info_system / leveraged / catalog control / AO）| A4 phase（直接擴 BaseReconciliationService）|
| F4 | jedi-auth Query Entity 加 `_in_email_domain` / `_in_name_prefix` 補 fuzzy DB-side filter | Prod 大 tenant feedback 後評估 |
| F5 | OSCAL party ↔ project_participant 雙寫設計（issue 修補 D）| 未來 OSCAL 匯出規劃時 |
| F6 | Dev DB `oscal_responsible_parties.role_id` 髒資料 audit / 清理（issue 修補 C）| A3 ship 後執行 audit SQL |
| F7 | docx parser 拿到 fuzzy 標記後預覽 UI 互動（A5-similar UI）| A5 phase（既有 docx preview 已 partial cover）|

---

## 3. Architecture

### 3.1 File 結構

```
domain/oscal/service/
├── reconciliation/                              # 新 sub-folder
│   ├── __init__.py
│   ├── base.py                                  # BaseReconciliationService[TParsed, TEntity]
│   ├── match_method.py                          # MatchMethod StrEnum
│   ├── _normalizers.py                          # 共用 helpers（_strip_plus_alias / _normalize_name / _strip_org_suffix）
│   ├── person_reconciler.py                     # PersonReconciler
│   └── organization_reconciler.py               # OrganizationReconciler
└── party_reconciliation_service.py              # 既有，改門面（dispatch by party_type）
```

> **底線命名 `_normalizers.py`**：標示為 sub-folder 內部 helper module，外部不該 import。base / reconciler 之間共用 normalize 規則。

**為何開 sub-folder**：A4 後總共 ~10 個檔（base + enum + 7 reconciler + 門面），全攤平在 `service/` 下會跟既有 30+ 個 service file 混在一起、稀釋 grep 效率。`domain/oscal/parser/` 已有 sub-folder precedent。

### 3.2 BaseReconciliationService 抽象基類

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

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 4 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]:
        """Batch reconcile; mutates each TParsed in place + returns same list."""
        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:
        """Three-stage fallback: exact → normalized → fuzzy → UNMATCHED."""
        # Stage 1: exact
        candidate = self._try_exact_match(parsed, tenant_id)
        if candidate is not None:
            self._apply_match(parsed, candidate, MatchMethod.EXACT, 1.0)
            return
        # Stage 2: normalized exact
        candidate = self._try_normalized_match(parsed, tenant_id)
        if candidate is not None:
            self._apply_match(parsed, candidate, MatchMethod.NORMALIZED, 1.0)
            return
        # Stage 3: fuzzy (uses candidate cache)
        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
        # No match
        self._apply_unmatched(parsed)

    # --- Subclass hooks（必須實作）---

    @abstractmethod
    def _try_exact_match(
        self, parsed: TParsed, tenant_id: Optional[int]
    ) -> Optional[TEntity]:
        """Use exact lookup key (e.g. email lower+trim, name trim) via domain service."""

    @abstractmethod
    def _try_normalized_match(
        self, parsed: TParsed, tenant_id: Optional[int]
    ) -> Optional[TEntity]:
        """Apply normalization (+alias removal, 全形半形 conversion) then exact lookup."""

    @abstractmethod
    def _try_fuzzy_match(
        self, parsed: TParsed, tenant_id: Optional[int]
    ) -> tuple[Optional[TEntity], MatchMethod]:
        """Client-side fuzzy filter against candidate cache; returns (entity, method)."""

    @abstractmethod
    def _apply_match(
        self,
        parsed: TParsed,
        entity: TEntity,
        method: MatchMethod,
        confidence: float,
    ) -> None:
        """Mutate parsed in place to record the match.

        Subclass owns which attrs to mutate — base class does NOT assume any
        field names. Conventional mutations:
          - PersonReconciler:        parsed.matched_user_id = entity.id
          - OrganizationReconciler:  parsed.matched_org_unit_id = entity.id
        Always mutate parsed.match_method = method + parsed.match_confidence = confidence
        (the two A3 new fields on ParsedParty).
        """

    @abstractmethod
    def _apply_unmatched(self, parsed: TParsed) -> None:
        """Mutate parsed to UNMATCHED state.

        Subclass owns matched_*_id reset (typically leaves at None).
        Always sets parsed.match_method = MatchMethod.UNMATCHED + parsed.match_confidence = 0.0.
        """

    # --- Cache helpers（subclass 可呼叫）---

    def _get_or_load_candidates(self, loader) -> List[TEntity]:
        """Lazy-load candidate list once per reconciler instance lifetime.

        Exception 行為：與既有 PartyReconciliationService (line 71-72 / 92-93)
        對齊 — exception 後不快取空 list、不快取 None；當次 reconcile 視為「拉不到候選」
        直接 → UNMATCHED；下次 reconcile call（DI 重 build instance）會 retry。
        """
        if self._candidate_cache is not None:
            return self._candidate_cache
        try:
            result = loader() or []
        except Exception:
            return []  # ← 不寫入 cache，下次 retry（跟既有行為一致）
        self._candidate_cache = result
        return result
```

**Hook contract**：subclass 必須實作 5 個 abstractmethod。`reconcile()` + `_reconcile_one()` + `_get_or_load_candidates()` 在 base 內共用，不允許 override（**convention only**，Python 不強制）。

### 3.3 PartyReconciliationService 門面

```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
```

**為何保留門面（不砍直接讓 caller 用 PersonReconciler / OrganizationReconciler）**：
- `reconcile(parties)` 處理的是 mixed list — 由門面負責 `party_type` dispatch（domain knowledge）
- 兩個 caller（docx + A2）公開介面 0 行變動，既有 8 unit test 全綠保住
- A4 加新 dataclass（ParsedDevice / ParsedInfoSystem 等）不會走此門面（不屬 party domain），命名語意維持準確

---

## 4. Data Structures

### 4.1 MatchMethod enum（新建 `domain/oscal/service/reconciliation/match_method.py`）

```python
from enum import StrEnum


class MatchMethod(StrEnum):
    EXACT = "exact"                              # email/name 完全相符（既有行為）
    NORMALIZED = "normalized"                    # +alias / 全形半形 normalize 後 exact
    FUZZY_EMAIL_DOMAIN = "fuzzy_email_domain"    # person 限定：email domain + nickname 對齊
    FUZZY_NAME_PREFIX = "fuzzy_name_prefix"      # organization 限定：剔除尾綴後 exact
    UNMATCHED = "unmatched"                      # 都不中
```

用 `StrEnum`：JSON 序列化直接是字串值，跟既有 GRC 模組慣例一致。

### 4.2 ParsedParty 加欄位（改 `domain/oscal/parser/ssp_intermediate.py`）

```python
@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  # 預設 UNMATCHED
    match_confidence: float = 0.0                      # 0.0 ~ 1.0
```

**回溯相容性**：兩個新欄位都有 default，既有 caller / unit test fixture / `write_strategy.write_parties` 完全不受影響。

### 4.3 Confidence 規則（A3 寫死）

| match_method | confidence | matched_*_id 行為 | A5 UI 預期 |
|--------------|-----------|------------------|------------|
| `EXACT` | `1.0` | 填值 | 不需 user 確認，預設 confirmed |
| `NORMALIZED` | `1.0` | 填值（系統判定為「事實相符」）| 同 EXACT — 不需 user 確認，但 UI 可顯示「自動修正」icon 提示 user 系統做了 normalize |
| `FUZZY_EMAIL_DOMAIN` | `0.7` | 填值 | UI 預選 + 提示「請確認此 user 是否為對應人員」，user 可推翻 |
| `FUZZY_NAME_PREFIX` | `0.7` | 填值 | UI 預選 + 提示「請確認此組織是否為對應單位」，user 可推翻 |
| `UNMATCHED` | `0.0` | 維持 None | UI 提示「無對應系統紀錄，請手動選現有 / 純文字保留 / inline 新建」 |

A3 不做動態 confidence 打分；future-proof 欄位留給 A4 device matcher 如有需要可細分梯度。

> **A5 預覽 UI contract**：A4/A5 phase 寫 preview UI 時，**EXACT 跟 NORMALIZED 共用 path**（一律不擋 confirm）；FUZZY_* 共用 path（一律擋 confirm 等 user 拍板）；UNMATCHED 走 inline 新建 / 選現有 / 純文字三選一 path（沿用 §1.3 raw-requirement §3.2 既有規範）。所以雖然 EXACT / NORMALIZED 都 confidence=1.0，但 match_method 仍寫入 JSONB 是為了 A5 UI 顯示「自動修正」提示（透明性給 user）。

### 4.4 match_method 落地策略

不寫入 `oscal_parties` table（OSCAL 規範鏡像不汙染運行時 metadata）；只活在：

1. Parse 階段：寫入 `parse_jobs.parsed_result` JSONB（A5 預覽 UI 反序列化讀）
2. Confirm 階段：用完即丟（OSCAL DB 不留痕跡）

JSONB schema 是 free shape，A5 預覽 UI 反序列化新欄位 — **不需 migration**。

---

## 5. Fuzzy Match 演算法

### 5.1 PersonReconciler 三階段降級

```
Input: ParsedParty(party_type='person', name='陳小明', email_address='alice+work@acme.com')
Tenant: 102

Stage 1 — Exact match（既有行為）
  email = parsed.email_address.strip().lower()
  if not email: → UNMATCHED 結束
  candidates = user_domain.get_users(UserQueryEntity(email=email))
  if 找到 is_active: → EXACT, confidence=1.0, matched_user_id 填

Stage 2 — Normalized match（新增）
  normalized = _strip_plus_alias(email)
    # alice+work@acme.com → alice@acme.com
  if normalized != email:
      candidates = user_domain.get_users(UserQueryEntity(email=normalized))
      if 找到 is_active: → NORMALIZED, confidence=1.0, matched_user_id 填

Stage 3 — Fuzzy email domain（新增，client-side filter）
  domain = email.split('@', 1)[1] if '@' in email else None
  if not domain: → UNMATCHED 結束

  # candidate cache（每次 reconcile() call lifetime 一次）
  candidates = self._get_or_load_candidates(
      lambda: user_domain.get_users(UserQueryEntity())  # 全 tenant active，RLS 隔離
  )

  parsed_name = parsed.name.strip()
  for u in candidates:
      if not u.is_active or not u.email: continue
      if u.email.split('@', 1)[1].lower() != domain: continue
      # nickname 或 name 完全相符（normalized）
      if (_normalize_name(u.nickname) == _normalize_name(parsed_name)
              or _normalize_name(u.login_name) == _normalize_name(parsed_name)):
          → FUZZY_EMAIL_DOMAIN, confidence=0.7, matched_user_id 填
          break

→ 都不中 → UNMATCHED
```

### 5.2 OrganizationReconciler 三階段降級

```
Input: ParsedParty(party_type='organization', name='ACME 股份有限公司')
Tenant: 102

Stage 1 — Exact match（既有行為）
  trimmed = parsed.name.strip()
  if not trimmed: → UNMATCHED 結束
  query = OrgUnitQueryEntity(name=trimmed)
  if tenant_id: query.tenant_id = tenant_id
  candidates = org_unit_domain.get_org_units(query)
  if 找到: → EXACT, confidence=1.0, matched_org_unit_id 填

Stage 2 — Normalized match（新增）
  normalized = _normalize_name(trimmed)  # 全形 → 半形、collapse 連續空格
  if normalized != trimmed:
      candidates = org_unit_domain.get_org_units(OrgUnitQueryEntity(name=normalized, tenant_id=tenant_id))
      if 找到: → NORMALIZED, confidence=1.0, matched_org_unit_id 填

Stage 3 — Fuzzy name prefix（新增，client-side filter）
  stripped = _strip_org_suffix(normalized)
  if stripped == normalized: → UNMATCHED（無尾綴可剔除，fuzzy 沒意義）

  candidates = self._get_or_load_candidates(
      lambda: org_unit_domain.get_org_units(OrgUnitQueryEntity(tenant_id=tenant_id))
  )

  for o in candidates:
      o_stripped = _strip_org_suffix(_normalize_name(o.name))
      if o_stripped == stripped:
          → FUZZY_NAME_PREFIX, confidence=0.7, matched_org_unit_id 填
          break

→ 都不中 → UNMATCHED
```

### 5.3 共用 normalize 函式

```python
# domain/oscal/service/reconciliation/_normalizers.py（內部 helper）

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


def _normalize_name(name: Optional[str]) -> str:
    """全形空格 → 半形、連續空格 collapse、strip。
    不做大小寫轉換 — 中文不影響；英文「Alice Wang」vs「alice wang」算不同人，保守處理。
    """
    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:
    """Trailing match only — 只剔結尾。"""
    if not name:
        return name
    for suffix in ORG_SUFFIXES:
        if name.endswith(suffix):
            return name[:-len(suffix)].strip()
    return name
```

### 5.4 Candidate Cache 行為

- **範圍**：單一 reconcile() call lifetime
- **觸發**：第一個 fuzzy 階段才拉（exact / normalized 都中的 case 不浪費 query）
- **位置**：reconciler instance attribute（base class 的 `self._candidate_cache`）
- **生命週期**：DI Factory provider，每次 build 新 instance → 等於每次 reconcile call 一個全新 cache，不跨 request

**容量考量**：dev tenant 102 目前 user < 100、org < 50；prod 預估 < 1000 user / < 200 org per tenant；一次 query 拉全 list 在 Python 端比對 < 50ms 可接受。**若 prod tenant user > 10000 → follow-up F4**（jedi-auth Query Entity 加 `_in_email_domain` DB-side filter）。

### 5.5 Edge case 行為

| Case | 行為 |
|------|------|
| `email_address` 為 None / 空 | UNMATCHED |
| `name` 為 None / 空 | UNMATCHED |
| domain query exception | 既有 try/except swallow → UNMATCHED（沿用 PartyReconciliationService 既有 line 71-72 + 92-93 pattern）|
| email 多 `@`（「a@b@c.com」）| `split('@', 1)[1]` 取第一個 `@` 後段，照原樣處理 |
| email 沒 `@`（user 亂填）| Stage 3 跳過，UNMATCHED |
| organization name 無尾綴 + 不中 | UNMATCHED（Stage 3 跳過）|

---

## 6. Caller 切換 + DI Wiring

### 6.1 三個 caller 切換策略

| Caller | A3 前 | A3 後 | 變動範圍 |
|--------|-------|-------|---------|
| docx flow — parse-time（`SspDocxImportAppService.upload_and_parse:258-264`）| 直接 `self._reconciliation.reconcile(parties)` | **0 行變動**（仍呼叫門面，門面內部委派）| 0 |
| docx flow — confirm-time（`SspDocxImportAppService:489-490`，write_parties 前）| 同上 reconcile 既有呼叫 | **0 行變動**（同上）| 0 |
| A2 superset flow（`SspExcelImportAppService._confirm_superset_flow:251`）| inject 但**沒呼叫**reconcile | 寫入 oscal_parties 前加 `self._reconciliation.reconcile(parsed.parties_org + parsed.parties_person, tenant_id=...)` | +1~3 行 |
| A2 update flow（`SspExcelImportAppService._confirm_update_flow:325`）| 同上 | 同上 | +1~3 行 |

### 6.2 A2 寫入順序（superset / update flow 都套）

```
_confirm_*_flow（既有 @transaction scope）
  ↓
1. self._reconciliation.reconcile(...)   ← A3 新增
   → ParsedParty 帶上 matched_user_id / matched_org_unit_id / match_method / match_confidence
  ↓
2. ModuleFrameWriteStrategy.write_parties(parties=..., context_type='module_frame', context_id=mf_id)
   ← 既有寫入路徑不動；write_parties 內部只看 matched_*_id 是否 None
  ↓
3. parse_job.parsed_result 更新 = 加 match_method / match_confidence 標記
   ← 給 A5 預覽 UI 讀；不入 oscal_parties table
```

### 6.3 A3 期間 fuzzy 行為差異（已知妥協）

| Caller | A3 期間 fuzzy 處理 |
|--------|---------------------|
| docx flow | 既有 docx 是 parse + confirm 分階段（design-A1 §15.3 既有預覽 contract 已有 UI）— `match_method=FUZZY_*` 在預覽頁可 user 拍板 |
| A2 path | A5 預覽 UI 還沒做 — A3 期間 fuzzy **自動寫入**（行為等同 matched）；A5 上線後改為 user 拍板 |

**列 design.md §11 follow-up F2**：A5 phase 必補 A2 fuzzy 的 user 拍板路徑。

### 6.4 DI Container 變動

`di_containers/oscal/oscal_containers.py`：

```python
# 新增（user / org_unit domain service 跨 container 引用 auth_container，沿用既有 line 490/491 pattern）
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）
party_reconciliation_service = providers.Factory(
    PartyReconciliationService,
    person_reconciler=person_reconciler,
    organization_reconciler=organization_reconciler,
)

# 既有 caller wiring（line 550 + 574）完全不動
# - ssp_docx_import_app_service 仍 inject party_reconciliation_service
# - ssp_excel_import_app_service 仍 inject party_reconciliation_service
```

### 6.5 Cucumber Regression（compliance-manager-test repo）

新建 `features/regression/module-frame/05-ssp-docx-import-party-match.feature`，5 scenarios：

| # | Scenario | 預期 |
|---|----------|------|
| 1 | docx 含 person email 完全相符 system user | API response `matched_user_id` 填值，`match_method='exact'` |
| 2 | docx 含 organization name 完全相符 system org | `matched_org_unit_id` 填值，`match_method='exact'` |
| 3 | docx 含 person email 完全不相符 | `matched_user_id` is null，`match_method='unmatched'` |
| 4 | docx 含 person email +alias（`alice+work@acme.com` vs system `alice@acme.com`）| `matched_user_id` 填值，`match_method='normalized'` |
| 5 | docx 含 organization name 帶尾綴（「ACME 股份有限公司」vs system「ACME」）| `matched_org_unit_id` 填值，`match_method='fuzzy_name_prefix'`（**fuzzy scenario**）|

跑法：user 端 GitLab env 配齊後跑 — A3 ship 條件之一。**Plan T6 標：若 env 未配齊，scenarios 撰寫完成可接受 partial ship（列 follow-up F-T8）**。

**Ship 後 regression baseline**：A3 shipped 後，docx import 的 party match regression baseline = **這 5 個 scenarios**。任何後續 phase 動到 PartyReconciliationService / PersonReconciler / OrganizationReconciler / fuzzy 演算法時，這 5 個 scenarios 必過。Changelog 內標明此 baseline，給 A4 / A5 implementer 開工前 reference。

---

## 7. Pre-flight Verification 結果（已驗）

| # | 假設 | 結果 |
|---|------|------|
| 1 | PartyReconciliationService caller 盤點 | ✅ 兩個 caller：`SspDocxImportAppService:58` + `SspExcelImportAppService:50`。DI 已 wire 兩端（`oscal_containers.py:550, 574`）。既有 8 unit test 在 `tests/test_party_reconciliation_service.py` |
| 2 | A2 confirm flow 沒實際串 reconcile | ✅ **handoff 假設屬實** — `_confirm_superset_flow:251` + `_confirm_update_flow:325` 內無任何 reconcile() 呼叫 |
| 3 | jedi-auth Query Entity 既有欄位 | ⚠️ **暴露限制** — `UserQueryEntity` 只接 email / nickname / login_name exact，**無 email_domain / 模糊查詢欄位**；`OrgUnitQueryEntity` 不接 name prefix / like。Q4 fuzzy 改用 client-side filter（拉全 tenant list） |
| 4 | cucumber 既有 docx import scenario 守 matched/unmatched | ⚠️ **沒守** — 既有只有 `features/showcase/training/01-pm-02c-resource-library-docx-import.feature`（教學錄影級），無 regression scenario assert `matched_user_id`。A3 ship 前必補 |
| 5 | test repo 結構 | ✅ `features/regression/module-frame/` + `steps/module-frame/` + `pages/module-frame/`。A3 新 scenarios 放 `features/regression/module-frame/05-*.feature` |

---

## 8. Acceptance Criteria

- [ ] 新建 `domain/oscal/service/reconciliation/{__init__,base,match_method,person_reconciler,organization_reconciler}.py` 5 個檔
- [ ] 新建 `domain/oscal/service/reconciliation/_normalizers.py` 共用 helper（`_strip_plus_alias` / `_normalize_name` / `_strip_org_suffix`）
- [ ] `PartyReconciliationService` 改門面，公開介面 `reconcile(parties, tenant_id)` 簽章不變
- [ ] `ParsedParty` 加 `match_method` + `match_confidence` optional 欄位（default UNMATCHED / 0.0）
- [ ] DI container 新增 `person_reconciler` / `organization_reconciler` Factory；門面改注入兩 reconciler
- [ ] 既有 `tests/test_party_reconciliation_service.py` 8 個 case 全綠不改
- [ ] 新增 unit test ~38 個（PersonReconciler 12 + OrgReconciler 10 + Base 5 + 門面 3 + ParsedParty 3 + integration 4 + matrix gap 1）全綠
- [ ] A2 `_confirm_superset_flow` + `_confirm_update_flow` 補 reconcile() 呼叫；fuzzy 在 A3 期間自動寫入（A5 後改 user 拍板）
- [ ] docx flow caller 0 行變動；既有 cucumber showcase scenario regression 通過
- [ ] 新增 cucumber regression `05-ssp-docx-import-party-match.feature` 含 5 scenarios（4 exact/normalized + 1 fuzzy）
- [ ] Changelog `YYYY-MM-DD-feat-party-matcher-shared.md`（type: feat、modules: oscal）完成
- [ ] BE 既有 205 test 全綠 + A3 新增 test 全綠 → 累計 ~248 test
- [ ] `docs/features/FR-011.2-2605-ssp-import-export-phase2/README.md` tracker A3 row 標 `BE shipped ✅` + commit chain
- [ ] **partial ship 允許**：若 user 端 GitLab env 仍未配齊，cucumber scenarios 撰寫完成但跑不過可接受；標 follow-up T8

---

## 9. 風險與緩解

| # | 風險 | 影響 | 緩解 |
|---|------|------|------|
| R1 | A2 fuzzy 自動寫入跟 docx 行為短暫不一致（A3~A5 期間）| user 在 A2 path 看不到 fuzzy 提示 | 接受妥協；plan T3 註解 + §11 follow-up F2 標 A5 必補 |
| R2 | candidate cache 在 prod 大 tenant（user > 10000）拉全 list 慢 | reconcile 超時 | A3 不處理；follow-up F4 — jedi-auth Query Entity 加 `_in_email_domain` |
| R3 | docx parser 既有抽 email 帶空格 / 特殊字元邊界 case | fuzzy 比對誤判 | `_normalize_name` 規則寫嚴（不做 case-insensitive 英文比對）；unit test cover 邊界 |
| R4 | Cucumber GitLab env 未配齊 → A3 regression 跑不過 | ship 卡關 | 允許 partial ship（A1 / A2 已有先例）；scenarios 撰寫完成等 env |
| R5 | `_strip_org_suffix` 列表寫死無法 cover 中文公司類別變體（如「行」「商行」「企業」）| fuzzy 漏配 | A3 第一版列 5 種常見（股份有限公司 / 有限公司 / 公司 / Inc / Ltd / Corp / LLC）；prod feedback 後擴充 |
| R6 | Generic 基類 + abstractmethod 對團隊新成員心智負擔 | onboarding 慢 | `base.py` 寫 docstring 解釋 4 個 hook 意圖（屬「non-obvious why」例外，允許寫 docstring）|
| R7 | A2 `_confirm_*_flow` 串接後 transaction scope 跨多 domain service 寫入失敗 | partial write 留垃圾 | 既有 `@transaction` scope 已 cover；reconcile + write_parties 同 scope 內，任一失敗整批 rollback |

---

## 10. 跨 repo 工作

| Repo | 工作 |
|------|------|
| compliance-manager-be（主）| Architecture 抽出 + ParsedParty 欄位 + DI wiring + A2 串接 + fuzzy 演算法 + unit test + integration test |
| compliance-manager-fe | A3 phase **不動 FE**（match_method 標記已有 schema 接，預覽 UI fuzzy 互動在 A5 補）|
| jedi-* | **不動**（reconciliation 維持在主專案）|
| compliance-manager-test | Cucumber regression：5 scenarios at `features/regression/module-frame/05-ssp-docx-import-party-match.feature` + page object 加新 step |
| Changelog | `docs/changelog/YYYY-MM-DD-feat-party-matcher-shared.md`（type: feat、modules: oscal）|

---

## 11. Implementation Reality / Reconciliation

（實作過程中對原始 design 的偏離 / 擴增紀錄；A3 Session E 收口時統一補。）

### 11.1 必出條目

1. **Test count §8 vs 實作**：design §8 acceptance criterion 第 7 條原寫「新增 unit test ~38 個」**漏算 13 個 `_normalizers.py` helper test**（`_strip_plus_alias` / `_normalize_name` / `_strip_org_suffix`）。實際 A3 ship 後 A3 + 既有 party reconciliation 套件 75 passed（13 normalizers + 7 base + 3 facade + 16 person + 16 org + 6 A2 integration + 4 ParsedParty fixture + 8 既有 party），其中 A3 新增 67 個。以 75 為實際 ship baseline。

2. **`base.py` `_get_or_load_candidates` exception 路徑**：實作走「`return []` 完全靜默 + 不寫入 cache」（subsequent call 仍重試 loader），與既有 docx parse-time `app/oscal/service/ssp_docx_import_app_service.py:258-264` 的 `try/except logger.warning` swallow 行為不一致 — 後者會記錄 warning。
   - **判斷**：base 內部 cache exception 屬「effort 性 retry」，污染 caller log 反而干擾 — 維持 silent return；caller side 的 `_write_all_data` 串接點仍走 `logger.warning` 樣式（沿用 docx pattern）。屬可接受差異。

### 11.2 Session C 累積偏差（T1+T2）

3. **Plan T1.7 — `__init__.py` export 範圍縮減**：原 plan 要求 `reconciliation/__init__.py` export 4 個（`BaseReconciliationService` / `MatchMethod` / `PersonReconciler` / `OrganizationReconciler`）。實作時 export `PersonReconciler` / `OrganizationReconciler` 會 trigger 對 `domain/oscal/parser/ssp_intermediate.py` 的 circular import（reconciler 內部 import `ParsedParty`；`ParsedParty` 內部 import `MatchMethod`），縮減為只 export 2 個（`BaseReconciliationService` + `MatchMethod`）。Reconciler 由 caller / DI 直接從 sub-module import — 不影響使用體驗。

4. **Plan T2.1 — 既有 8 個 `test_party_reconciliation_service.py` fixture 必須改注入**：原 plan 寫「既有 8 test 0 行改動全綠」。實作上 facade `__init__` signature 從「直接接 jedi_auth domain service」改為「接兩個 reconciler」後，fixture 必須改用 `_make_facade()` helper inject — assertion 仍 0 行改動。屬 plan 假設過於樂觀，不影響行為。

5. **Plan import path 細節**：plan code snippet 寫 `from jedi_auth.domain.entity.user_query_entity import UserQueryEntity`（單數 entity），但既有 code + jedi-auth 套件實際路徑是 `jedi_auth.domain.entities.user_query_entity`（複數 entities）。實作對齊複數既有路徑 — minor 但留紀錄避免下次 implementer 重撞。

### 11.3 Session D 累積偏差（T3+T4）

6. **Plan T3.2/T3.3 — reconcile 插入點 vs 實際 DRY 受惠**：原 plan 寫「`_confirm_superset_flow` + `_confirm_update_flow` 兩處各插一次 reconcile」。實作 verify 時發現兩 flow 都呼叫 `_write_all_data` — 插一處（`_write_all_data` 開頭，在 `write_parties` 前）即可 DRY 受惠，**call chain 自動受惠**。
   - test 仍透過 `confirm_import("u", {}, user_context)` → `_confirm_*_flow` 進入，`reconcile.call_count == 1` assertion 成立。

7. **Plan T3.4 test fixture 簽章 vs 實際**：原 plan 寫 `decisions=[], overrides={}` 參數，實際 `_confirm_superset_flow(job, parsed_result, payload, user_context)` 簽章不同。test 改用 `confirm_import("u", {}, user_context)` 走完整 path 比較自然 — 而非直接 call private method。

8. **既有 29 個 `test_ssp_excel_import_app_service.py` fixture backward-compat 路徑**：A3 改動 `_write_all_data` 後既有 29 test 不需改 fixture — `party_reconciliation_service` 在 `__init__` 是 optional default `None`，`_write_all_data` 內 `if self._reconciliation is not None` 守門保住舊行為。新 A2 integration test 自己 inject `mock_reconciliation`。Backward-compat 路徑刻意保留，避免動既有 fixture noise。

### 11.4 行為差異 / A5 follow-up

9. **A3 期間 A2 fuzzy 自動寫入**：design §1.2 + §6 寫 fuzzy 是「中間狀態」，但 A3 不引入預覽 UI（在 A5）。A3 期間 docx flow + A2 flow 兩條 path 對 fuzzy 結果**都自動寫入**（confidence=0.7 但 caller 看不到差別），與 unfortunate edge case 一致；A5 phase 才補預覽 UI 拍板路徑（已列 follow-up F2）。

### 11.5 已驗證對齊 design 無偏差條目

除上述 9 條外，A3 實作的下列項目完全對齊 design-A3：
- §3.2 BaseReconciliationService 5 hook + reconcile() / _reconcile_one() / _get_or_load_candidates() shape
- §5.1 Person 三階段 fallback algorithm（exact email → strip+alias normalized → domain+nickname/login_name fuzzy）
- §5.2 Organization 三階段 fallback algorithm（exact name → normalize 全形半形 → strip suffix fuzzy）
- §5.4 candidate cache lifecycle（per reconciler instance；exception 不污染 cache）
- §6.1 DI wiring（`person_reconciler` / `organization_reconciler` Factory；門面 `party_reconciliation_service` 改注入兩 reconciler）
- §10 跨 repo 工作劃分（BE 主 + compliance-manager-test cucumber，FE / jedi-* 不動）

---

## 12. 執行階段切分（5 session）

| Session | 範圍 | Task | 預估 | Commit checkpoint | 換 session 觸發 |
|---------|------|------|------|-------------------|----------------|
| **A**（當前）| Design 落地 | brainstorm 收斂 + 寫 design-A3.md + spec review loop + user review | 0.5d | `docs(ssp-import-export-phase2): A3 design.md` | design.md user 同意 + commit |
| **B** | Plan 落地 | invoke writing-plans → 寫 implementation-plan-A3.md + plan review + user review | 0.25d | `docs(ssp-import-export-phase2): A3 implementation-plan.md` | plan.md user 同意 + commit |
| **C** | 架構抽出 + docx 切換 | T1 + T2（BaseReconciliationService + reconcilers + ParsedParty + 門面化 + DI）| ~1d | `feat(oscal): A3 T1 base + reconcilers` / `feat(oscal): A3 T2 docx path facade + DI` | docx flow 100% 不變 + 既有 8 + 新增 ~30 unit test 全綠 |
| **D** | A2 串接 + fuzzy 演算法 | T3（A2 _confirm_*_flow 補 reconcile）+ T4（fuzzy 演算法）| ~0.75d | `feat(oscal): A3 T3 wire A2 confirm flows` / `feat(oscal): A3 T4 fuzzy match algorithm` | A2 path reconcile 通過 + fuzzy unit test 全綠 |
| **E** | Test 補齊 + cucumber + 收尾 | T5（integration test）+ T6（cucumber regression 跨 test repo）+ T7（changelog + tracker + §11 reconciliation + final SUMMARY）| ~1.25d | `test(oscal): A3 T5 integration tests` / `test(compliance-manager-test): A3 T6 cucumber regression` / `docs(ssp-import-export-phase2): A3 收尾 + SUMMARY` | A3 整段 shipped（partial ship 允許）|

**總計**：5 session、~3.75d（含 plan/design 寫作 + 5 implementation sessions）

### 換 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/YYYY-MM-DD-a3-<session 字母 + 階段>-next.md`
4. Session E 是 task arc 收口 → 對話歷史按日期 dump 到 `docs/conversation-history/<date>/ssp-import-export-phase2/`

---

## 13. 設計決策溯源（brainstorm 拍板）

| # | 議題 | 拍板 | 理由摘要 |
|---|------|------|---------|
| Q1 | Matcher 擺哪層 | `domain/oscal/service/` | 既有位置 + 純 domain logic + 不跨層 |
| Q2 | Match 狀態 contract | 三態 `matched / fuzzy / unmatched`，fuzzy 必進 user 確認 | A1 VLOOKUP 未重算 / docx email 帶空格等常見 case 有救，同時避免 false positive 自動接受 |
| Q3 | docx 路徑切換策略 | 直接切換（無 feature flag）| 內部 refactor 不該點火條雙路徑；cucumber regression 守 |
| Q4 | Fuzzy 演算法精細度 | Normalize + email domain（person）/ name prefix（org）| 規則明確、false positive 低；不走 Levenshtein（threshold 難拍板）|
| Q4' | jedi-auth Query Entity 不支援 fuzzy filter | Client-side filter（拉全 tenant list） | A3 不動 jedi-* 套件；prod 大 tenant 才考慮 DB-side filter |
| Q5 | 跟 issue 修補 B 整併 | 不整併 | A3 scope 純 user/org matcher；role normalize 屬 enum 規範問題、不同域 |
| Q6 | A4 擴展契約 | Strategy Pattern + Generic Base `BaseReconciliationService[TParsed, TEntity]` | 抽 hook contract 對 A4 5 個新 matcher 直接擴；剃除重複 batch 邏輯 |
| Q3' | A2 串接 reconcile 範圍 | A3 順手串 A2 path | design-A2 §3.2 本來就標 A3 要補；一次到位避免 A4/A5 dispatch 狀態混亂 |

完整 brainstorm 對話紀錄將於 Session E（task arc 收口）dump 到 `docs/conversation-history/<YYYY-MM-DD>/ssp-import-export-phase2/`。

---

**下一步**：spec review loop → user review → 換 Session B invoke writing-plans 寫 `implementation-plan-A3.md`。
