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 抽象基類上
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。
PartyReconciliationService 內部邏輯抽成 BaseReconciliationService[TParsed, TEntity] 抽象基類;A4 直接擴 hookmatched / unmatched 二態升級 matched / fuzzy / unmatched 三態,cover「VLOOKUP 未重算」/「email 帶 +alias」/「組織名帶尾綴」常見 case_confirm_*_flow 對 reconcile 的呼叫,讓 docx + A2 兩條 import flow 行為一致對應 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)。
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 不破壞_confirm_superset_flow + _confirm_update_flow 補 reconcile() 呼叫+alias 移除後 exact / email domain + nickname 對齊features/regression/module-frame/05-ssp-docx-import-party-match.feature 含 5 scenarios(4 exact/normalized + 1 fuzzy)docs/changelog/YYYY-MM-DD-feat-party-matcher-shared.md(type: feat、modules: oscal)| # | 不在 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) |
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。
# 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 resultHook contract:subclass 必須實作 5 個 abstractmethod。reconcile() + _reconcile_one() + _get_or_load_candidates() 在 base 內共用,不允許 override(convention only,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)domain/oscal/service/reconciliation/match_method.py)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 模組慣例一致。
domain/oscal/parser/ssp_intermediate.py)@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 完全不受影響。
| 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)。
不寫入 oscal_parties table(OSCAL 規範鏡像不汙染運行時 metadata);只活在:
parse_jobs.parsed_result JSONB(A5 預覽 UI 反序列化讀)JSONB schema 是 free shape,A5 預覽 UI 反序列化新欄位 — 不需 migration。
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
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
# 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 nameself._candidate_cache)容量考量: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)。
| 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 跳過) |
| 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 行 |
_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
| 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 拍板路徑。
di_containers/oscal/oscal_containers.py:
# 新增(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新建 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。
| # | 假設 | 結果 |
|---|---|---|
| 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 |
| # | 風險 | 影響 | 緩解 |
|---|---|---|---|
| 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 |
| 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) |
(實作過程中對原始 design 的偏離 / 擴增紀錄;A3 Session E 收口時統一補。)
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。
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。
_write_all_data 串接點仍走 logger.warning 樣式(沿用 docx pattern)。屬可接受差異。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 — 不影響使用體驗。
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 假設過於樂觀,不影響行為。
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 重撞。
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 自動受惠。
confirm_import("u", {}, user_context) → _confirm_*_flow 進入,reconcile.call_count == 1 assertion 成立。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。
既有 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。
除上述 9 條外,A3 實作的下列項目完全對齊 design-A3:
person_reconciler / organization_reconciler Factory;門面 party_reconciliation_service 改注入兩 reconciler)| 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)
按 CLAUDE.md「做 summary 觸發完整收尾」段:
docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/YYYY-MM-DD-a3-<session 字母 + 階段>-next.mddocs/conversation-history/<date>/ssp-import-export-phase2/| # | 議題 | 拍板 | 理由摘要 |
|---|---|---|---|
| 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。