# Phase A2 Implementation Plan — Excel Parser + 解析 API

> **Phase**：A2
> **級別**：中型
> **依賴**：A1 已 ship（樣板下載 + TEMPLATE_VERSION v1.1.0）
> **預計工時**：BE 2.75d + test 0.5d ≈ 3.5 working day（FE 不動，A5 才補預覽 UI）
> **對應 design**：`docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A2.md`

---

## Task 切分概覽

| Task | 主題 | repo | 預估 | 依賴 |
|------|------|------|------|------|
| 0 | Pre-flight verification（5 個假設複驗 + 既有 RLS policy / mirror 結構查證）| BE | 0.25d | — |
| **1a** | **A1 樣板 v1.2.0 minor bump — 07 sheet 加 `include_in_profile` 欄位**（A2 prerequisite）| BE | 0.25d | T0 |
| 1 | DB migration — 新建 `oscal.ssp_excel_parse_jobs` table + 權限 + RLS | BE | 0.25d | T0 |
| 2 | Domain + Infra 層 — entity / model / repo / mapper / domain service | BE | 0.5d | T1 |
| 3 | Excel Parser 核心 — `app/oscal/service/excel_parser/` 純函式 + 9 sheet 解析 + validation | BE | 1d | T0, T1a |
| 4 | App Service skeleton — `SspExcelImportAppService` 4 個 public method（upload / get / discard / confirm）| BE | 0.5d | T2, T3 |
| 5 | Route + Serializer — 4 個 Resource + marshmallow schema | BE | 0.25d | T4 |
| 6 | DI wiring + Error code 註冊 | BE | 0.25d | T5 |
| 7 | Unit test — parser / app service mock 路徑 ≥ 50 case | BE | 0.5d | T3, T4 |
| 8 | BE smoke + changelog ×2 + tracker | BE | 0.25d | T7 |

> Task 1, 2 必須序列；Task 3 可跟 T1/T2 並行（純函式不依賴 DB），但 T3 要 T1a 完成（parser 對齊 v1.2.0 column index）。
> Task 5 (Route) 必須在 T4 完成後（介面要對齊 app service signature）。
> 並行最佳化：T1 ∥ T1a；T2 完成後 ∥ T3。
> T1a 不動 DB，是 A1 樣板 generator code patch + TEMPLATE_VERSION bump — 可在 T0 結束後立刻並 T1 起跑。

---

## Task 0 — Pre-flight Verification

> design-A2 §9 已列 6 項已驗事實；T0 補剩餘 5 個 design 未確認假設，動 code 前一次釐清。

### 0.1 verify `ssp_docx_parse_jobs` RLS policy 結構

```bash
# 看 docx parse_jobs migration 內 RLS policy 寫法
grep -n "POLICY\|ALTER TABLE.*ssp_docx_parse_jobs\|ROW LEVEL" \
  scripts/sql/2026-05-01-ssp-docx-parser-consolidated.sql \
  scripts/sql/ssp_docx_parse_jobs_migration.sql 2>/dev/null
```

**期待結果**：找到既有 policy SQL（如 `tenant_id = ANY(current_setting('app.allowed_tenant_paths')::int[])`）。A2 直接 copy 同樣 policy 套到 `ssp_excel_parse_jobs`。

**Impact**：design-A2 §5 RLS 段定稿。

### 0.2 verify `ProfileService.add_profile` data payload schema

```bash
grep -n "def add_profile\|catalog_uid\|include_controls\|metadata" \
  ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/app/services/profile/profile_service.py \
  | head -30
```

**期待結果**：確認 `data` dict 接受哪些 key（title / description / status / catalog_uid / include_controls / ...），決定 A2 confirm 階段組 payload 的方式。

**Impact**：Task 4 confirm_import 寫 profile 創建邏輯時不踩雷。

### 0.3 verify `ModuleFrameService.add_module_frame` payload schema

```bash
sed -n '120,190p' app/module_frame/service/module_frame_service.py
```

**期待結果**：確認 add_module_frame 接受 `oscal_profile_uid` 直接傳，還是要自己組 profile 後再回填 MF。

**Impact**：Task 4 superset flow 順序（先建 profile → 拿到 profile_uid → 建 MF？還是反過來）。

### 0.4 verify `file_upload_service` 兩軌 storage helper

```bash
sed -n '155,200p' app/oscal/service/ssp_docx_import_app_service.py
```

**期待結果**：確認 mirror pattern：tempfile for parser + FileStorage wrap for upload_service。A2 直接 copy 相同段 code。

**Impact**：Task 4 upload_and_parse 段 storage 處理直接照搬。

### 0.5 verify `party_reconciliation_service` 是否已 wire 可直接用

```bash
grep -rn "party_reconciliation_service\|PartyReconciliation" di_containers/ 2>/dev/null | head -10
grep -n "def reconcile" domain/oscal/ app/oscal/ 2>/dev/null | head -10
```

**期待結果**：確認 reconciliation service 已存在且 DI 已 wired（docx import 已用）。A2 confirm 階段可直接 inject。

**Impact**：Task 6 DI；確認需不需要新建 reconciliation 入口或沿用既有。

### Acceptance

- [ ] 5 個 verification 結果寫入 implementation log（commit 訊息 / changelog）
- [ ] design-A2.md 若有假設不符 → 補 §15 Reconciliation 條目，**不照舊硬幹**

---

## Task 1a — A1 樣板 v1.2.0 Minor Bump（A2 prerequisite）

> design-A2 §13.1 引入：07 sheet 加 `include_in_profile` checkbox 欄位；A1 generator code patch；TEMPLATE_VERSION bump v1.1.0 → v1.2.0。

### 1a.1 verify checkbox 寫入機制

```bash
# openpyxl 內 Data Validation list 跟 Form Control checkbox 哪個跨 Excel 版本穩定
poetry run python3 -c "
from openpyxl import Workbook
from openpyxl.worksheet.datavalidation import DataValidation
wb = Workbook()
ws = wb.active
dv = DataValidation(type='list', formula1='\"TRUE,FALSE\"', allow_blank=True)
ws.add_data_validation(dv)
dv.add('B2:B10')
ws['B2'] = True
wb.save('/tmp/checkbox_test.xlsx')
print('TRUE/FALSE dropdown OK')
"
```

**期待結果**：用 `DataValidation type=list formula1='\"TRUE,FALSE\"'` 即可（openpyxl 不直接支援 Form Control checkbox；Data Validation 下拉 TRUE/FALSE 在 Excel / Numbers 都顯示 dropdown，UX 接近 checkbox）。

**決策**：若 verify 結果不滿意（如 Numbers 顯示異常）→ 改用「直接 cell 顯示 TRUE/FALSE 純文字 + DataValidation」；A2 parser 端對 `TRUE/True/true/1/Y/是` 都接受。

### 1a.2 sheet_definitions.py 改動

```python
# app/module_frame/excel_template/sheet_definitions.py
# SHEET_CONTROLS columns 在 control_id 後加：
SHEET_CONTROLS = SheetDef(
    sheet_name="07_控制項與AO",
    columns=(
        ColumnDef("statement_id", ..., hidden=True),          # A 欄
        ColumnDef("include_in_profile", "field.include_in_profile",
                  required=False, enum_values=("TRUE", "FALSE")),   # B 欄（A2 新增）
        ColumnDef("control_id", ..., required=True),                # C 欄（原 B → C）
        ColumnDef("control_name", ...),                              # D 欄
        ColumnDef("objective_id", ...),
        ColumnDef("objective_name", ...),
        ColumnDef("impl_status", ..., enum_values=(...), required=True),
        ColumnDef("statement", ..., required=True),
        ColumnDef("reference_doc", ...),
    ),
)
```

### 1a.3 generator filled mode 預填行為

| Generator 路徑 | `include_in_profile` 預填 |
|---------------|---------------------------|
| MF-scoped blank / filled | 父 row 全部 `TRUE` |
| FW-version-scoped blank（superset） | 父 row 全部 `FALSE`（user 訪談時逐筆勾選） |

子 row（AO）此欄留空（openpyxl `None`）— checkbox 只在父 row 有意義。

### 1a.4 TEMPLATE_VERSION bump

```python
# app/module_frame/excel_template/sheet_definitions.py（或 generator 主檔）
TEMPLATE_VERSION = "v1.2.0"   # 原 v1.1.0
```

### 1a.5 A1 unit test fixture 重排 column index

既有 A1 test 內若用 `ws['C2']` 直接讀 control_id 的，column letter 都要 +1（B→C, C→D 等）。

```bash
grep -rn "ws\\['[A-Z]2'\\]\\|cell(row=2,\\s*column=[0-9]" \
  tests/test_excel_template_*.py | head -20
```

逐筆同步更新。

### 1a.6 i18n key

`config/translations/zh_Hant_TW/LC_MESSAGES/messages.po` + `en` 加 `field.include_in_profile` 對應：
- zh: 「納入 Profile」
- en: "Include in Profile"

### 1a.7 Acceptance

- [ ] 下載 MF-scoped filled 樣板：07 sheet B 欄全 `TRUE`
- [ ] 下載 FW-version-scoped blank 樣板：07 sheet B 欄全 `FALSE`
- [ ] DataValidation 下拉只有 TRUE / FALSE 可選
- [ ] 00_說明 R1 顯示 `v1.2.0`
- [ ] 既有 A1 test 全綠（column letter 重排已修）
- [ ] 新增 3-5 個 test：filled 預填 TRUE / blank superset 預填 FALSE / 子 row 該欄空
- [ ] changelog `docs/changelog/YYYY-MM-DD-tweak-ssp-import-template-v1.2.0.md` 完成

---

## Task 1 — DB Migration

### 1.1 檔案改動

| 檔案 | 動作 |
|------|------|
| `scripts/sql/2026-05-XX-ssp-excel-parse-jobs.sql` | 新建 — 對齊 design-A2 §5；含 Date 註解 + GRANT + RLS policy（T0.1 結果）|

### 1.2 SQL（依 T0.1 結果定稿）

對齊 design-A2 §5 的 5 段 SQL：建 table / 索引 ×2 / GRANT / ENABLE RLS + CREATE POLICY。

### 1.3 Acceptance

- [ ] 用 `cmmgr` (不是 cm_app — 後者受 RLS 擋住) 跑 migration 成功
- [ ] `\d oscal.ssp_excel_parse_jobs` 看到所有欄位
- [ ] `cm_app` user 可 SELECT / INSERT 此表（GRANT 生效）
- [ ] tenant_a 的 record，tenant_b user 查不到（RLS 生效）

---

## Task 2 — Domain + Infra 層

### 2.1 新建檔案

```
domain/oscal/entity/ssp_excel_parse_job_entity.py
domain/oscal/repository/i_ssp_excel_parse_job_repo.py
domain/oscal/service/ssp_excel_parse_job_domain_service.py
infra/oscal/model/ssp_excel_parse_job.py
infra/oscal/mapper/ssp_excel_parse_job_mapper.py
infra/oscal/repository/ssp_excel_parse_job_repo_impl.py
```

### 2.2 結構（mirror docx 對應檔案）

- Entity：`SspExcelParseJobEntity` 跟 model 同欄位 + uid
- Mapper：`to_entity` / `to_model`
- Repo impl：繼承 `BaseRepositoryImpl[Entity, QueryEntity, Model, Mapper]`，session 自動有
- Domain service：常用 method
  - `create(source_type, source_uid, template_version, metadata, file_name, file_size, tenant_id, user) -> Entity`
  - `update_status(uid, status, user, file_path=None) -> None`
  - `write_parsed_result(uid, parsed_result_dict, user) -> None`
  - `write_error(uid, error_code, error_message, user) -> None`
  - `write_import_summary(uid, summary, user) -> None`
  - `get_one(uid) -> Entity | None`
  - `discard(uid, user) -> None`（軟刪 is_active=False）

### 2.3 Acceptance

- [ ] 純 import 不抛 error
- [ ] 寫個快速 smoke 在 `pytest` fixture 內：起 transaction → create + get_one → 拿到對應 entity
- [ ] mapper 雙向轉換 round-trip OK（unit test）

---

## Task 3 — Excel Parser 核心

### 3.1 新建目錄

```
app/oscal/service/excel_parser/
├── __init__.py
├── parser.py                    # ExcelParser 主類 + parse() 入口
├── sheet_handlers.py            # 9 個 sheet 各自 handler（parse_metadata / parse_parties_org / ...）
├── validators.py                # role enum / required / unknown enum value 驗證
├── version_check.py             # _check_template_version + SUPPORTED_TEMPLATE_VERSION_RANGE
└── types.py                     # ParsedExcel / ParsedSection / ValidationError dataclass
```

### 3.2 ParsedExcel 結構（types.py）

```python
@dataclass
class ValidationError:
    sheet: str
    row: int
    field: str
    code: str
    message: str
    blocking: bool = False

@dataclass
class ParsedExcel:
    template_version: str
    metadata: dict | None                  # 01 sheet
    parties_org: list[dict]                # 02 sheet
    parties_person: list[dict]             # 03 sheet
    devices: list[dict]                    # 04 sheet
    info_systems: list[dict]               # 05 sheet
    leveraged: list[dict]                  # 06 sheet
    controls_with_aos: list[dict]          # 07 sheet（含 objectives 巢狀）
    ref_docs: list[dict]                   # 08 sheet
    validation_errors: list[ValidationError]

    def to_dict(self) -> dict:
        """寫入 parse_job.parsed_result (JSONB) 用"""
```

### 3.3 ExcelParser.parse 入口

```python
class ExcelParser:
    def parse(self, file_path: str, source_type: str, source_uid: str) -> ParsedExcel:
        wb = load_workbook(file_path, data_only=True)
        template_version = self._read_template_version(wb)
        self._check_template_version(template_version)
        
        validation_errors: list[ValidationError] = []
        result = ParsedExcel(
            template_version=template_version,
            metadata=self._parse_metadata_sheet(wb, validation_errors),
            parties_org=self._parse_parties_org_sheet(wb, validation_errors),
            parties_person=self._parse_parties_person_sheet(wb, validation_errors),
            devices=self._parse_devices_sheet(wb, validation_errors),
            info_systems=self._parse_info_systems_sheet(wb, validation_errors),
            leveraged=self._parse_leveraged_sheet(wb, validation_errors),
            controls_with_aos=self._parse_controls_sheet(wb, validation_errors),
            ref_docs=self._parse_ref_docs_sheet(wb, validation_errors),
            validation_errors=validation_errors,
        )
        return result
```

### 3.4 09 sheet 父子 row 邏輯（design-A2 §6.4）

照 design-A2 範例 code 實作；對齊 A1 generator 的「父 row statement_id 空 / 子 row statement_id=AO uid」contract。

### 3.5 Validators

```python
VALID_PARTICIPANT_ROLES = {"manager", "reviewer", "auditor", "viewer"}

def validate_person_role(value: str, sheet: str, row: int) -> tuple[str, ValidationError | None]:
    role = (value or "").strip().lower()
    if not role:
        return "", None
    if role not in VALID_PARTICIPANT_ROLES:
        return role, ValidationError(
            sheet=sheet, row=row, field="role", code="INVALID_ROLE",
            message=f"role '{role}' 不在 manager/reviewer/auditor/viewer 範圍",
            blocking=True,
        )
    return role, None
```

對齊 design-A2 §7 issue 修補 A。

### 3.6 Acceptance

- [ ] 純函式，不依賴 DB / DI
- [ ] 入口拿到合法 v1.0.0 / v1.1.0 樣板 → 解析完整 ParsedExcel
- [ ] v2.0.0 樣板 → raise `BadRequestError(GRC_EXCEL_TEMPLATE_VERSION_UNSUPPORTED)`
- [ ] 缺 `00_說明` sheet → raise `BadRequestError(GRC_EXCEL_INVALID_FILE)`
- [ ] 03 role = `admin` → ValidationError(blocking=True) 加入 result
- [ ] 07 sheet 子 row 沒對應父 → ValidationError 加入；該 AO 跳過
- [ ] VLOOKUP autofill 欄位 cached value 是 None → fallback 看 user 是否直接填 email/name

---

## Task 4 — App Service skeleton

### 4.1 新建檔案

`app/oscal/service/ssp_excel_import_app_service.py`

### 4.2 結構（mirror SspDocxImportAppService）

```python
_EXCEL_MAX_SIZE = 10 * 1024 * 1024  # 10MB
_PARSE_JOB_TTL_HOURS = 24

class SspExcelImportAppService:
    def __init__(self, parse_job_domain_service, parser, file_upload_service,
                 module_frame_domain_service, profile_service, module_frame_service,
                 party_domain_service, responsible_party_domain_service,
                 oscal_framework_version_domain_service, catalog_control_domain_service,
                 party_reconciliation_service=None, ...):
        self._parse_job = parse_job_domain_service
        self._parser = parser
        # ... 其他 dep
    
    @transaction
    def upload_and_parse(self, file, source_type, source_uid, metadata, user_context) -> dict:
        # 1. file validation（副檔名 / size）
        # 2. source 存在性 + 權限檢查
        # 3. 建 parse_job(status=pending)
        # 4. 走兩軌 storage：tempfile + file_upload_service
        # 5. self._parser.parse(tmp_path, source_type, source_uid) → ParsedExcel
        # 6. write_parsed_result(parsed.to_dict()) + status=awaiting_review
        # 7. unlink tempfile
        # 8. return {parse_uid, status, file_uid, summary}
    
    @transaction
    def get_parse_result(self, parse_uid, user_context) -> dict:
        # 1. get_one + tenant check + ttl check + is_active
        # 2. return job.to_dict() including parsed_result
    
    @transaction
    def discard_parse(self, parse_uid, user_context) -> dict:
        # 1. permission check
        # 2. domain_service.discard
        # 3. return {parse_uid, status: 'discarded'}
    
    @transaction
    def confirm_import(self, parse_uid, payload, user_context) -> dict:
        # 1. get + status / ttl / blocking-validation 守門
        # 2. dispatch by source_type:
        #    - 'framework_version' → _confirm_superset_flow
        #    - 'module_frame'      → _confirm_update_flow
        # 3. write_import_summary + status=completed
    
    def _confirm_superset_flow(self, job, parsed_result, decisions, overrides, user_context):
        # 1. 取 framework_version + catalog
        # 2. 從 controls_with_aos 篩納入 profile 的 control_ids → catalog_control_uid list
        # 3. ProfileService.add_profile({catalog_uid, include_controls, title, ...})
        # 4. ModuleFrameService.add_module_frame({oscal_profile_uid=新, oscal_framework_version_uid, name, ...})
        # 5. _write_parties / _write_items / _write_control_defaults / _write_ref_docs (context_type='module_frame', context_id=新 MF.id)
        # 6. return import_summary
    
    def _confirm_update_flow(self, job, parsed_result, decisions, overrides, user_context):
        # 1. 取既有 MF（已驗存在）
        # 2. 不動 profile
        # 3. _write_parties / items / control_defaults / ref_docs upsert
        # 4. return import_summary
    
    # _write_* helper：A2 直接寫，不靠 decisions 拆 append/update/skip（A5 補完）
```

### 4.3 Acceptance

- [ ] 4 public method 都有 `@transaction`
- [ ] upload_and_parse 撞 invalid file → 對應 error code
- [ ] confirm 端點 superset 路徑能跑通：建 profile → 建 MF → 寫 parties/items/control_defaults 不抛 exception
- [ ] confirm 端點 update 路徑能 upsert
- [ ] Helper `_write_*` 沿用既有 strategy / domain service（不另寫 ORM 操作）

---

## Task 5 — Route + Serializer

### 5.1 新建檔案

```
api/oscal/serializers/ssp/ssp_excel_import.py
api/oscal/routes/ssp/ssp_excel_import_route.py
```

### 5.2 Serializer（mirror ssp_docx_import.py 結構）

```python
class SspExcelImportParseRequestSchema(Schema):
    """POST /ssp-excel-imports/parse — form fields"""
    source_type = fields.Str(required=True, validate=validate.OneOf(['framework_version', 'module_frame']))
    source_uid = fields.Str(required=True)
    metadata = fields.Dict(required=False)   # JSON-encoded form field

class SspExcelImportParseResponseSchema(Schema):
    """POST /parse response"""
    parse_uid = fields.Str()
    status = fields.Str()
    file_uid = fields.Str()
    summary = fields.Dict(allow_none=True)
    error_code = fields.Str(allow_none=True)
    error_message = fields.Str(allow_none=True)

class SspExcelImportPreviewResponseSchema(Schema):
    """GET /<uid>"""
    uid = fields.Str()
    source_type = fields.Str()
    source_uid = fields.Str()
    status = fields.Str()
    file_uid = fields.Str()
    file_name = fields.Str()
    parsed_result = fields.Dict()
    created_at = fields.DateTime()
    ttl_expires_at = fields.DateTime()

class SspExcelImportConfirmRequestSchema(Schema):
    """POST /<uid>/confirm"""
    decisions = fields.List(fields.Dict(), required=False)
    overrides = fields.Dict(required=False)

class SspExcelImportConfirmResponseSchema(Schema):
    parse_uid = fields.Str()
    status = fields.Str()
    module_frame_uid = fields.Str()
    profile_uid = fields.Str(allow_none=True)
    import_summary = fields.Dict()
```

### 5.3 Route（mirror ssp_docx_import_route.py）

4 個 Resource：`SspExcelImportParseRoute` / `SspExcelImportRoute (GET+DELETE)` / `SspExcelImportConfirmRoute`。
URL prefix `/api/1.0`，註冊到 `api/oscal/__init__.py` 既有 blueprint。

### 5.4 Acceptance

- [ ] 起 BE → curl 4 個 endpoint → 401 (no JWT) / 200 (with JWT 走通) 都對
- [ ] Swagger UI 顯示 4 個新 endpoint
- [ ] Form field 解析正確（multipart + metadata JSON-encoded）

---

## Task 6 — DI Wiring + Error Code

### 6.1 DI

`di_containers/oscal/oscal_containers.py`：
- 新建 `ssp_excel_parse_job_repo` (Singleton)
- 新建 `ssp_excel_parse_job_domain_service` (Factory)
- 新建 `excel_parser` (Singleton — 純函式 class)
- 新建 `ssp_excel_import_app_service` (Factory，inject 上述 + 既有 domain services)

`di_containers/containers.py`：sub-container 已在主 container 內，wiring 自動掃描即可。

### 6.2 Error Code

`common/code/grc_error_code.py` 加（序號從 design-A2 §9 已驗：400065+ / 404033+ / 412013+）：

```python
GRC_EXCEL_INVALID_FILE                = ("Excel 檔格式無效", "GRC_400065")
GRC_EXCEL_FILE_TOO_LARGE              = ("Excel 檔超過大小限制（10MB）", "GRC_400066")
GRC_EXCEL_SOURCE_TYPE_INVALID         = ("Excel 匯入 source_type 無效（需 framework_version 或 module_frame）", "GRC_400067")
GRC_EXCEL_SOURCE_UID_REQUIRED         = ("Excel 匯入 source_uid 必填", "GRC_400068")
GRC_EXCEL_TEMPLATE_VERSION_UNSUPPORTED = ("Excel 樣板版本不支援，請重新下載最新樣板", "GRC_400069")
GRC_EXCEL_VALIDATION_ERRORS_BLOCKING  = ("Excel 內容含阻斷級錯誤，請修正後重新上傳", "GRC_400070")
GRC_EXCEL_PARSE_JOB_NOT_FOUND         = ("Excel 解析任務不存在", "GRC_404033")
GRC_EXCEL_PARSE_JOB_NOT_AWAITING      = ("Excel 解析任務狀態不是 awaiting_review，無法確認匯入", "GRC_412013")
GRC_EXCEL_PARSE_JOB_TTL_EXPIRED       = ("Excel 解析任務已過期（24h）", "GRC_412014")
```

### 6.3 Acceptance

- [ ] 起 BE 不抛 wire error
- [ ] `flask routes | grep ssp-excel` 看到 4 個 endpoint
- [ ] error code import 成功

---

## Task 7 — Unit Test

### 7.1 新建測試檔

```
tests/test_ssp_excel_parser.py                   # parser 純函式 ≥ 25 case
tests/test_ssp_excel_import_app_service.py       # app service mock domain ≥ 20 case
tests/test_ssp_excel_parse_job_mapper.py         # mapper round-trip ≥ 5 case
```

### 7.2 Parser 測試覆蓋（≥ 25 case）

- 合法 v1.0.0 / v1.1.0 / v1.2.0 樣板各跑一次 → ParsedExcel 完整
- v2.0.0 → 抛 GRC_EXCEL_TEMPLATE_VERSION_UNSUPPORTED
- 缺 00_說明 sheet → 抛 GRC_EXCEL_INVALID_FILE
- 缺 01-08 sheet 各別 case
- 03 role 合法 4 值 each
- 03 role = "admin"/"system-owner"/"管理員"/空 → 對應 ValidationError
- 07 父 row + 多個子 row：解析成 nested 結構
- 07 子 row 找不到父 → ValidationError + 跳過
- 07 同 control_id 重複父 row → 後者覆蓋前者
- 07 `include_in_profile` v1.2.0 樣板：TRUE / FALSE / true / "Y" / "是" / 空 → 對應 `_target_in_profile` 值
- 07 `include_in_profile` v1.0/v1.1 樣板 fallback：所有父 row `_target_in_profile=True`（向後相容）
- VLOOKUP cached None → fallback 邏輯
- 01 sheet hidden col framework_version_uid 讀出
- Validation errors 列表結構正確

### 7.3 App Service 測試覆蓋（≥ 20 case，mock domain）

- upload_and_parse：副檔名錯 / 檔案過大 / source_type 錯 / source_uid 缺 / source 不存在
- upload_and_parse 走通：parsed_result 寫入 parse_job + return 正確 dict
- get_parse_result：TTL 過期、is_active=False、跨 tenant 都 404
- discard_parse：軟刪後再 get → 404
- confirm_import：status 不是 awaiting_review → 412
- confirm_import 帶 blocking validation_error → 400
- confirm_import superset flow：mock ProfileService.add_profile 被呼叫，`include_controls` 只含 `_target_in_profile=True` 的 control_id
- confirm_import superset flow：add_module_frame 用 add_profile 返回的 profile_uid
- confirm_import update flow：**忽略 07 sheet 的 `_target_in_profile`**，既有 MF profile 不動，parties/items upsert 被呼叫
- confirm_import TTL 過期 → 412
- _write_parties role enum 已被 validator 擋（mock：role invalid 進不到此 helper）

### 7.4 Mapper / Domain Service smoke

- Mapper 雙向 round-trip OK（entity ↔ model）
- Repo `create / get_one / update_status` 走得通（fixture transaction scope）

### 7.5 Acceptance

- [ ] `pytest tests/test_ssp_excel_*.py` 全綠
- [ ] 累計 ≥ 50 個新 test case

---

## Task 8 — BE Smoke + Changelog + Tracker

### 8.1 BE Smoke

```bash
TOKEN=$(./scripts/dev_login.sh)
FW_VERSION_UID="..."   # dev DB 既有 framework_version
MF_UID="..."           # dev DB 既有 MF

# Superset flow — 拿 A1 superset 樣板填好後上傳
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Tenant-ID: 102" \
  -F "file=@/tmp/test_superset.xlsx" \
  -F "source_type=framework_version" \
  -F "source_uid=$FW_VERSION_UID" \
  -F 'metadata={"target_mf_name":"A2 Smoke MF","target_group":"smoke","target_version":"0.1","target_frequency":"annual"}' \
  http://localhost:8000/api/1.0/ssp-excel-imports/parse

# 拿 parse_uid → GET → confirm
PARSE_UID="..."
curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:8000/api/1.0/ssp-excel-import/$PARSE_UID

curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"decisions":[],"overrides":{}}' \
  http://localhost:8000/api/1.0/ssp-excel-import/$PARSE_UID/confirm

# MF update flow — 拿 A1 filled 樣板填好上傳
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@/tmp/test_update.xlsx" \
  -F "source_type=module_frame" \
  -F "source_uid=$MF_UID" \
  http://localhost:8000/api/1.0/ssp-excel-imports/parse

# 重複 GET + confirm
```

驗 dev DB：

```sql
-- 確認新 MF / profile 寫入
SELECT name, oscal_profile_uid, oscal_framework_version_uid FROM compliance.module_frames WHERE name = 'A2 Smoke MF';
SELECT uid, status FROM oscal.oscal_profiles WHERE uid = '<新 profile uid>';
SELECT COUNT(*) FROM oscal.oscal_parties WHERE uid IN (...);
SELECT COUNT(*) FROM oscal.ssp_system_implementation_items WHERE scope_type='module_frame' AND scope_id=<新 MF.id>;
```

### 8.2 Changelog（兩份）

**Changelog #1**：`docs/changelog/YYYY-MM-DD-tweak-ssp-import-template-v1.2.0.md`
- frontmatter：`type: tweak`, `modules: [ssp-import-template]`, `breaking: false`
- 樣板 minor bump v1.1.0 → v1.2.0：07 sheet 加 `include_in_profile` checkbox
- A1 generator code patch + unit test fixture column letter 重排
- 動機：A2 parser superset flow 需要明確的「納入 profile」標記

**Changelog #2**：`docs/changelog/YYYY-MM-DD-feat-ssp-excel-import.md`
- frontmatter：`type: feat`, `modules: [ssp-excel-import, oscal]`, `issue: docs/issues/pending/2026-05-19-person-role-cross-domain-inconsistency.md`
- 需求說明（A2 phase 目標）
- 變更範圍（檔案清單）
- API 變更（4 個新 endpoint）
- 兩條 import flow 差異 + MF update flow 不支援改 profile（已知限制 §12 follow-up）
- 對 issue 修補 A 的處理（role enum validation 已落地）
- 測試結果（pytest output 摘要）

### 8.3 Tracker

`docs/features/FR-011.2-2605-ssp-import-export-phase2/README.md`：A2 row 從 `pending` → `shipped`，補 commit / changelog 連結。

### 8.4 Acceptance

- [ ] Smoke 兩條 flow 都走通 + dev DB 寫入正確
- [ ] Changelog 完成 + commit 內含 hash
- [ ] Tracker 更新

---

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

- [ ] 顯式 `git add <file>`，禁 `-am` / `-A`（subagent 也要在 dispatch prompt 明寫）
- [ ] Commit 含 `Co-Authored-By: Claude Opus 4.7 (1M context)` footer
- [ ] jedi-* 不動（design-A2 §9 已驗 ProfileService.add_profile 接口足夠）
- [ ] DDD 嚴格分層（Route 不碰 DB / App Service @transaction / Parser 純函式 / Strategy 沿用既有）
- [ ] BE service code 改完提醒 user `lsof -ti:8000 | xargs kill -9 && nohup ...`
- [ ] T0 verify 結果不符 → 主動修 design-A2.md §15 Reconciliation，不照舊硬幹
- [ ] 跨 phase 過渡 commit：每 Task 完成各自一個 commit；T0 verify 結果可併在 T1 migration commit 內
- [ ] 不寫 docstring / 註解除非真有 non-obvious 的 why
- [ ] 階段性 commit 不用問（子 task 完成直接 commit）

---

## 風險檢核點

- [ ] T0 結束：若 `ProfileService.add_profile` 無法直接接 title override → 修 design-A2 §11 風險表 + 設計補強路徑
- [ ] T3 結束：parser unit test 跑出來 row count 跟手構 fixture 不符 → 檢查 A1 generator 跟 parser 對 hidden cols 的 column index 是否一致
- [ ] T4 結束：confirm superset flow 跑通後檢查 dev DB — 確認 oscal_profiles + profile_controls 數量對齊 user 標納入的 control 數
- [ ] T7 結束：unit test 數量 < 50 case → 補測試覆蓋（特別是 parser edge case）

---

**下一步**：等使用者 review design-A2 + plan-A2 → GO → 進 Task 0 verify。
