# FR-044 稽核紀錄 xlsx 匯入 Implementation Plan

> **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:** 讓稽核員 / PM 在稽核執行頁上傳顧問的逐條檢查紀錄 xlsx，系統解析成控制層判定＋逐 AO 觀察＋佐證連結，預覽修改後批次寫入本輪 AR。

**Architecture:** Mirror FR-043 的三段式匯入管線（parse → preview → confirm），寫入全走 `AssessmentResultAppService` 既有方法（`judge_finding` / `create_observation` / `add_risk` / `link_risk_findings`），單一 `@transaction` 全成全敗。xlsx 是 AO 粒度（59 列），系統是控制層粒度（NIST 17 條）→ 透過 **framework profile 對照表（AG 15 → NIST 17，一對多）** 做「AO 判定 → 控制層判定」聚合。FR-043 的 adapter registry 與 `_METHOD_KEYWORDS` 上收為共用 `import_adapter/` 基建，AP docx 與 AR xlsx 各為一個 instance。

**Tech Stack:** Flask-RESTful + marshmallow、DDD（domain/grc + infra/grc）、`dependency-injector`、SQLAlchemy + PostgreSQL RLS、openpyxl（xlsx 讀取）、jedi_oscal_v2（FindingState / AR domain services）、Vue 3 + PrimeVue（FE wizard）。

---

## Pre-flight 已驗證事實（2026-07-03，寫 code 前的地基）

| 事實 | 值 | 影響 |
|---|---|---|
| **控制 id 對照是一對多** | xlsx 15 個 AG id（`AC.L1-b.1.i`）→ catalog **17** 個 NIST id（`AC.L1-3.1.1`）。唯一非 1:1：`PE.L1-b.1.ix` → `PE.L1-3.10.3`+`3.10.4`+`3.10.5` | 對照表用 `AG → list[NIST]`；聚合跑在 NIST finding（17 筆）；`PE.L1-b.1.ix` 6 列照順序拆 2/1/3 |
| **AO 數字完美對齊** | xlsx AO 數 6/2/6/5/3/3/2/4/6/8/2/6/2/1/3 ↔ catalog `assessment-objective` part 數逐條相等（含 PE 2+1+3=6） | `derive_ao_pairs` 直接可用；「xlsx 列數 = 系統 AO 數」不合者整 AG 區塊降級人工核對 |
| **finding 粒度＝控制層** | `assessment_findings.target_id = NIST control_id`（實查 ar_result 476 → `IA.L1-3.5.1`），`target_type='objective-id'` | 匯入 = 逐 NIST 控制 `judge_finding`（聚合判定）+ 逐 AO `create_observation` |
| **`create_observation` 硬寫 now** | `assessment_result_app_service.py:518` `collected=datetime.now(utc)` | 需加 optional `collected` 參數（Phase D） |
| **`judge_finding` 只綁單一 observation** | `observation_uid`（單數）累加進 `related_observations` | 需擴充可綁多筆（Phase D，一控制多 AO 觀察） |
| **`_check_auditor` 為私有** | `assessment_result_app_service.py:111` | 提為 public gate helper 供 import 復用（Phase D） |
| **`FindingState` 三態** | met / not_met / pending（無 not_applicable） | Phase A 加 `NOT_APPLICABLE` |
| **`ArFindingMatrixService`** | `_STATE_TO_TOKEN` = satisfied/not-satisfied/pending；`list_findings` stats = met/not_met/pending/total | Phase A 加 `'not-applicable'` token + `na` stats 桶 |
| **`finalize_assessment` 守門** | `stats["pending"]>0`→GRC_VERDICT_INCOMPLETE；每 risk 至少連一 finding→GRC_RISK_NO_FINDING_LINKED；POA&M 掃 `not-satisfied` | na 用新 token 天然不產 POA&M；stats na 桶加好後 pending 守門自動正確 |
| **FE 現況** | `RoundAuditReviewView.vue`：`judgedCount=met+not_met`(L128)、`verdictOptions` 三態(L112-115)、`stats` 三桶(L58)、tags(L633-635) | Phase A 全加 na |
| **parse job 表樣板** | `oscal.ap_docx_parse_jobs`（`TenantScopedMixinModel`）；**org_unit_id 曾漏補第二支 migration** | FR-044 建表 SQL **一次含 org_unit_id** |
| **檔案大小上限** | `_EXCEL_MAX_SIZE = 10MB`（`ssp_excel_import_app_service.py:47`） | AR xlsx 沿用 10MB |
| **error code 目前 max** | 400091 / 403060 / 404046 / 409033 / 412042 | FR-044 從各 band 下一個號起（見 Phase 各 task） |

**設計決策（Option A，user 2026-07-03 拍板）**：一對多控制照順序拆分；preview 依 **NIST 控制（17 組）** 分組、每組標註來源 AG index；聚合、判定、風險都跑在 NIST finding 層。

---

## File Structure

### 套件（jedi_oscal_v2，dev 走 path dependency，不 commit pyproject path 改動）
- Modify: `jedi_oscal_v2/common/enum/oscal_enums.py` — `FindingState` 加 `NOT_APPLICABLE`
- Modify: `jedi_oscal_v2/domain/service/ar/ar_finding_matrix_service.py` — token 對照 + stats na 桶

### BE 共用 import 基建（FR-043 泛化，本 FR 完成）
- Create: `app/grc/service/import_adapter/__init__.py`
- Create: `app/grc/service/import_adapter/method_keywords.py` — `_METHOD_KEYWORDS` + `detect_methods()`（自 airasia adapter 上收）
- Create: `app/grc/service/import_adapter/registry_base.py` — `UnsupportedFormat` + `RegistryBase`（loader 可注入）
- Modify: `app/grc/service/ap_report_parser/registry.py` / `airasia_cmmc_l1_v1.py` — 改 import 共用層，行為不變

### BE parse job 持久層（mirror ap_docx_parse_job → ar_xlsx_parse_job）
- Create: `infra/grc/model/ar_xlsx_parse_job.py`
- Create: `infra/grc/mapper/ar_xlsx_parse_job_mapper.py`
- Create: `infra/grc/repository/ar_xlsx_parse_job_repo_impl.py`
- Create: `domain/grc/entities/ar_xlsx_parse_job_entity.py`
- Create: `domain/grc/repository/i_ar_xlsx_parse_job_repo.py`
- Create: `domain/grc/service/ar_xlsx_parse_job_domain_service.py`
- Create: `scripts/sql/2026-07-03-ar-xlsx-parse-jobs.sql`（**含 org_unit_id**）

### BE framework profile + adapter
- Create: `app/grc/service/ar_report_parser/__init__.py`
- Create: `app/grc/service/ar_report_parser/base.py` — `ParsedArRecord` / `ParsedArRow` dataclass + `ArReportParserAdapter`
- Create: `app/grc/service/ar_report_parser/registry.py` — openpyxl loader instance of `RegistryBase`
- Create: `app/grc/service/ar_report_parser/airasia_cmmc_l1_ar_v1.py` — 亞航 adapter
- Create: `app/grc/service/ar_framework_profile/__init__.py`
- Create: `app/grc/service/ar_framework_profile/cmmc_l1.py` — AG→[NIST] 對照 + verdict 詞彙 + AO 對齊策略

### BE 匯入 app service + 對齊/配對
- Create: `app/grc/service/ar_import_app_service.py` — 主流程（parse/preview/confirm/discard）
- Create: `app/grc/service/ar_import/ao_alignment.py` — AG 區塊 → NIST 控制 AO parts 攤平對齊
- Create: `app/grc/service/ar_import/evidence_matcher.py` — 佐證分級配對
- Create: `app/grc/service/ar_import/aggregation.py` — 逐 NIST 控制聚合四規則
- Modify: `app/grc/service/assessment_result_app_service.py` — `create_observation` 加 `collected`、`judge_finding` 綁多 observation、提 public gate helper
- Modify: `common/code/grc_error_code.py` — 新 error codes

### BE route + serializer + DI
- Create: `api/project/routes/ar_import_route.py`
- Create: `api/project/serializers/ar_import.py`
- Modify: `api/project/__init__.py` — register routes
- Modify: `di_containers/grc/grc_containers.py` — wiring

### FE
- Create: `src/components/grc/ar-import/ArImportDialog.vue`（mirror `ap-docx-import/ApDocxImportDialog.vue`）
- Modify: `src/views/project/RoundAuditReviewView.vue` — 入口按鈕 + verdict 第四態 + stats na 桶 + judgedCount
- Modify: `src/config/api/api.js` — AR import 端點常數
- Modify: i18n（`verdict_not_applicable`、`stat_not_applicable`、匯入 wizard 文案）

### 測試（BE 在主專案 test/；E2E 在 compliance-manager-test）
- Create: `test/test_fr044_*.py`（adapter / aggregation / evidence / app service / package regression）

---

## Phase A — 套件三件事 + BE/FE na 第四態

> 目標：`not_applicable` 端到端可用，既有測試全綠（enum 加值的 regression）。此 Phase 不碰匯入，先讓「N/A」在系統站得住。

### Task A1: jedi_oscal_v2 接上 path dependency（dev-only）

**Files:**
- Modify: `pyproject.toml`（主專案，取消註解 jedi_oscal_v2 的 path 形式；**此改動不 commit**）

- [ ] **Step 1: 確認目前安裝形式**

Run: `python -c "import jedi_oscal_v2, os; print(os.path.realpath(jedi_oscal_v2.__file__))"`
判讀：若 realpath 已指向 `~/Projects/Jedicogy/.../jedi-oscal-v2/`（symlink/editable）→ 改套件源碼重啟即生效，**跳過 A1 的 pyproject 改動**；若指向 site-packages 的 pin 版本 → 執行 Step 2。

- [ ] **Step 2:（僅 pin 版才做）pyproject.toml 改 path 形式**

在 `[tool.poetry.dependencies]` 把 `jedi-oscal-v2` 那行改成本地 path（參照同檔其他 `path = "..."` 註解樣式），然後請 user 跑 `poetry update jedi-oscal-v2`（**不自己跑、不自己起服務**）。

- [ ] **Step 3: 提醒 user**：「jedi_oscal_v2 已接本地 path，改套件源碼後請重啟 BE」。此 pyproject 改動屬 dev-only，feature 收尾發版時才還原 pin。

### Task A2: `FindingState` 加 `NOT_APPLICABLE`

**Files:**
- Modify: `jedi_oscal_v2/common/enum/oscal_enums.py:31-37`
- Test: `jedi-oscal-v2/tests/ar/test_finding_matrix.py`（既有）

- [ ] **Step 1: 寫 failing test**（在套件 tests/ar/test_finding_matrix.py 加）

```python
def test_not_applicable_state_and_token():
    from jedi_oscal_v2.common.enum.oscal_enums import FindingState
    from jedi_oscal_v2.domain.service.ar.ar_finding_matrix_service import (
        _STATE_TO_TOKEN, _TOKEN_TO_STATE,
    )
    assert FindingState.NOT_APPLICABLE.value == "not_applicable"
    assert _STATE_TO_TOKEN["not_applicable"] == "not-applicable"
    assert _TOKEN_TO_STATE["not-applicable"] == "not_applicable"
```

- [ ] **Step 2: 跑測試確認 FAIL**

Run: `cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 && python -m pytest tests/ar/test_finding_matrix.py::test_not_applicable_state_and_token -v`
Expected: FAIL（`AttributeError: NOT_APPLICABLE` / KeyError）

- [ ] **Step 3: 加 enum 值**

```python
class FindingState(StrEnum):
    """控制項評估結果狀態。"""
    MET = "met"
    NOT_MET = "not_met"
    NOT_APPLICABLE = "not_applicable"
    PENDING = "pending"
```

### Task A3: `ArFindingMatrixService` token 對照 + stats na 桶

**Files:**
- Modify: `jedi_oscal_v2/domain/service/ar/ar_finding_matrix_service.py:42-48, 134-159`

- [ ] **Step 1: 加 token 對照**（`_STATE_TO_TOKEN`）

```python
_STATE_TO_TOKEN: Dict[str, str] = {
    FindingState.MET.value: "satisfied",
    FindingState.NOT_MET.value: "not-satisfied",
    FindingState.NOT_APPLICABLE.value: "not-applicable",  # OSCAL 無此值，比照 pending 存 product token
    FindingState.PENDING.value: "pending",
}
```
（`_TOKEN_TO_STATE` 為反向 comprehension，自動帶到）

- [ ] **Step 2: `list_findings` stats 加 na 桶**（`ar_finding_matrix_service.py:142-159`）

在 stats 初始化與回傳加 `FindingState.NOT_APPLICABLE.value`：
```python
stats = {
    FindingState.MET.value: 0,
    FindingState.NOT_MET.value: 0,
    FindingState.NOT_APPLICABLE.value: 0,
    FindingState.PENDING.value: 0,
}
...
return {
    "findings": findings,
    "stats": {
        "met": stats[FindingState.MET.value],
        "not_met": stats[FindingState.NOT_MET.value],
        "na": stats[FindingState.NOT_APPLICABLE.value],
        "pending": stats[FindingState.PENDING.value],
        "total": len(findings),
    },
}
```

- [ ] **Step 3: 更新 module docstring** 的 mapping 表註記（加 NOT_APPLICABLE → 'not-applicable'）。

- [ ] **Step 4: 跑套件全測試（regression 門檻）**

Run: `cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 && python -m pytest tests/ -q`
Expected: 全綠（含新 test）。**任一既有測試因 stats 多一桶失敗 → 修正該測試斷言（改成容忍 na 桶），不改行為。**

- [ ] **Step 5: Commit（套件 repo）**

```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2
git add jedi_oscal_v2/common/enum/oscal_enums.py jedi_oscal_v2/domain/service/ar/ar_finding_matrix_service.py tests/ar/test_finding_matrix.py
git commit -m "feat(FR-044): FindingState 加 NOT_APPLICABLE + matrix na token/stats 桶"
```
（**不 push、不發版、不推 Nexus** — 等 user 明示）

### Task A4: 主專案 `finalize_assessment` na 桶不擋 finalize（驗證，多半零改）

**Files:**
- Modify(可能): `app/grc/service/assessment_result_app_service.py:398-405, 758-761`
- Test: `test/test_fr044_finalize_na.py`

- [ ] **Step 1: 寫 test**：一輪 findings = 部分 met + 部分 not_applicable（無 pending、無 not_met）→ `finalize_assessment` 應通過、不產 POA&M。

```python
# 用既有 AR service test 樣式（記得 logger patch fixture，見 CLAUDE.md）
# 建 matrix → upsert 部分 finding 為 not_applicable → finalize 不 raise GRC_VERDICT_INCOMPLETE
```

- [ ] **Step 2: 跑確認**（na 桶加好後 `stats["pending"]` 天然不含 na）

Run: `pytest test/test_fr044_finalize_na.py -v`
Expected: PASS。**若 FAIL** 表示 `get_findings` 的 `stats["controls_with_not_met"]` 或 finalize 有硬編 met/not_met/pending 桶漏 na — 補上再測。

### Task A5: FE verdict 第四態 + stats na 桶 + judgedCount

**Files:**
- Modify: `src/views/project/RoundAuditReviewView.vue:58, 112-115, 128, 335-338, 633-635`
- Modify: i18n `lang.round_audit.verdict_not_applicable` / `stat_not_applicable`（zh_Hant_TW + en）

- [ ] **Step 1:** `stats` ref type 加 `na`（L58）：`{ met; not_met; na; pending; total }`，初始值同步。
- [ ] **Step 2:** `verdictOptions` 加第四項（L112-115）：`{ label: t('lang.round_audit.verdict_not_applicable'), value: 'not_applicable' }`。
- [ ] **Step 3:** `judgedCount`（L128）改 `stats.met + stats.not_met + stats.na`（na = 已判定）。
- [ ] **Step 4:** FE 控制層聚合（L335-338）加 na：`任一 not_met→not_met；全 na→not_applicable；含 pending→pending；否則→met`。
- [ ] **Step 5:** stats tags（L633-635）加 na tag（`stats.na`，severity secondary/info）。
- [ ] **Step 6:** `verdictSeverity` / `toTreeVerdict`（L619-621）處理 `not_applicable`（顯示灰或 info；tree verdict → null 不顯示 pass/fail 圓點）。
- [ ] **Step 7:** i18n 補鍵（zh + en），`pybabel` 不涉及（前端 i18n 走 FE lang 檔）。
- [ ] **Step 8: Commit（FE repo，顯式 add）**

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
git add src/views/project/RoundAuditReviewView.vue src/i18n/... 
git commit -m "feat(FR-044): 稽核判定加 not_applicable 第四態 + stats na 桶"
```

- [ ] **Step 9: Commit（BE package regression 用的 test）**

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
git add test/test_fr044_finalize_na.py app/grc/service/assessment_result_app_service.py
git commit -m "test(FR-044): finalize 不被 not_applicable 擋 + na 桶漣漪修正"
```

---

## Phase B — `ar_xlsx_parse_jobs` 表 + 持久層（mirror ap_docx_parse_job）

> 目標：解析工作記錄表可建可讀可軟刪。**建表 SQL 一次含 org_unit_id**（避開 FR-043 漏補的前例）。

### Task B1: SQL migration（含 org_unit_id、GRANT、RLS、schema_migrations）

**Files:**
- Create: `scripts/sql/2026-07-03-ar-xlsx-parse-jobs.sql`

- [ ] **Step 1: 寫 migration**（照 `2026-07-02-ap-docx-parse-jobs.sql` + org_unit_id 併入）

```sql
-- Date: 2026-07-03
-- =============================================================================
-- FR-044 稽核紀錄 xlsx 匯入 — ar_xlsx_parse_jobs 表
-- 一次含 org_unit_id（TenantScopedMixinModel 多租戶需要；避開 FR-043 漏補前例）
-- =============================================================================
CREATE TABLE oscal.ar_xlsx_parse_jobs (
    id             SERIAL PRIMARY KEY,
    uid            VARCHAR(36) NOT NULL UNIQUE,
    tenant_id      INTEGER NOT NULL,
    org_unit_id    INTEGER REFERENCES public.org_units(id),
    source_type    VARCHAR(20) NOT NULL DEFAULT 'ar_round',
    source_uid     VARCHAR(36) NOT NULL,          -- round_uid
    status         VARCHAR(20) NOT NULL DEFAULT 'pending',
    file_path      VARCHAR(255),
    file_name      VARCHAR(255),
    file_size      BIGINT,
    parsed_result  JSONB,
    error_code     VARCHAR(40),
    error_message  TEXT,
    import_summary JSONB,
    is_active      BOOLEAN NOT NULL DEFAULT TRUE,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_user   VARCHAR(50) NOT NULL,
    updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_user   VARCHAR(50) NOT NULL
);
CREATE INDEX idx_ar_xlsx_parse_jobs_source ON oscal.ar_xlsx_parse_jobs(source_type, source_uid);
CREATE INDEX idx_ar_xlsx_parse_jobs_status ON oscal.ar_xlsx_parse_jobs(status, created_at);
CREATE INDEX idx_ar_xlsx_parse_jobs_tenant ON oscal.ar_xlsx_parse_jobs(tenant_id);
CREATE INDEX idx_ar_xlsx_parse_jobs_org_unit_id ON oscal.ar_xlsx_parse_jobs(org_unit_id);
ALTER TABLE oscal.ar_xlsx_parse_jobs ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_ar_xlsx_parse_jobs ON oscal.ar_xlsx_parse_jobs
    USING (tenant_id::text = current_setting('app.user_id', true)
        OR current_setting('app.allowed_tenant_paths', true) LIKE '%' || tenant_id::text || '%');
GRANT SELECT, INSERT, UPDATE, DELETE ON oscal.ar_xlsx_parse_jobs TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE oscal.ar_xlsx_parse_jobs_id_seq TO cm_app;
INSERT INTO public.schema_migrations(filename, note) VALUES
  ('2026-07-03-ar-xlsx-parse-jobs.sql', 'FR-044 / 新建 oscal.ar_xlsx_parse_jobs（AR xlsx 匯入解析工作記錄，TTL 24h）')
ON CONFLICT (filename) DO NOTHING;
```

- [ ] **Step 2: 套用 DEV（cmmgr、port 25432、single-transaction）**

Run:
```bash
set -a; source .env; set +a
PGPASSWORD='<查 .env DB_SECRET>' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
  --single-transaction -v ON_ERROR_STOP=1 -f scripts/sql/2026-07-03-ar-xlsx-parse-jobs.sql
```
Expected: `CREATE TABLE` … `INSERT 0 1`，無 ERROR。

- [ ] **Step 3: 驗證**

Run: `PGPASSWORD='...' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c "\d oscal.ar_xlsx_parse_jobs"`
Expected: 含 `org_unit_id` 欄；RLS enabled。

- [ ] **Step 4: Commit**

```bash
git add scripts/sql/2026-07-03-ar-xlsx-parse-jobs.sql
git commit -m "feat(FR-044): ar_xlsx_parse_jobs 表 migration（含 org_unit_id/RLS/GRANT）"
```

### Task B2: entity / repo interface / model / mapper / repo impl / domain service

**Files:**（逐檔 mirror `ap_docx_parse_job`，把 Ap→Ar、docx→xlsx、`source_type` 預設 `'ar_round'`）
- Create: `domain/grc/entities/ar_xlsx_parse_job_entity.py`（`ArXlsxParseJobEntity` + `ArXlsxParseJobQueryEntity`，加 `org_unit_id` 欄位）
- Create: `domain/grc/repository/i_ar_xlsx_parse_job_repo.py`
- Create: `infra/grc/model/ar_xlsx_parse_job.py`（`BaseModel, TenantScopedMixinModel`、`__tablename__='ar_xlsx_parse_jobs'`、schema `oscal`）
- Create: `infra/grc/mapper/ar_xlsx_parse_job_mapper.py`
- Create: `infra/grc/repository/ar_xlsx_parse_job_repo_impl.py`
- Create: `domain/grc/service/ar_xlsx_parse_job_domain_service.py`（`create` 的 `source_type="ar_round"`）

- [ ] **Step 1:** 逐檔對照 `ap_docx_parse_job_*` 建立（entity 的 `__init__` 加 `org_unit_id: Optional[int] = None`；model 由 `TenantScopedMixinModel` 自動帶 tenant_id/org_unit_id，不重複宣告）。
- [ ] **Step 2: 寫 mapper roundtrip test**

**Files:** Test: `test/test_fr044_ar_xlsx_parse_job_mapper.py`
```python
def test_ar_xlsx_parse_job_mapper_roundtrip():
    from infra.grc.model.ar_xlsx_parse_job import ArXlsxParseJob
    from infra.grc.mapper.ar_xlsx_parse_job_mapper import ArXlsxParseJobMapper
    m = ArXlsxParseJob(id=1, uid="u", tenant_id=1, source_type="ar_round",
                       source_uid="r", status="pending", is_active=True)
    e = ArXlsxParseJobMapper.to_entity(m)
    assert e.uid == "u" and e.source_type == "ar_round"
```

- [ ] **Step 3: 跑測試**

Run: `pytest test/test_fr044_ar_xlsx_parse_job_mapper.py -v`
Expected: PASS

- [ ] **Step 4: Commit**

```bash
git add domain/grc/entities/ar_xlsx_parse_job_entity.py domain/grc/repository/i_ar_xlsx_parse_job_repo.py \
        infra/grc/model/ar_xlsx_parse_job.py infra/grc/mapper/ar_xlsx_parse_job_mapper.py \
        infra/grc/repository/ar_xlsx_parse_job_repo_impl.py domain/grc/service/ar_xlsx_parse_job_domain_service.py \
        test/test_fr044_ar_xlsx_parse_job_mapper.py
git commit -m "feat(FR-044): ar_xlsx_parse_job 持久層（entity/repo/mapper/domain service）"
```

---

## Phase C — 共用 import 基建泛化 + 亞航 AR adapter（TDD）

> 目標：把 FR-043 的 registry base + `_METHOD_KEYWORDS` 上收共用；建 AR xlsx adapter。**FR-043 AP docx 匯入測試全綠 = regression 門檻**。

### Task C1: 上收 `_METHOD_KEYWORDS` + `detect_methods` 到共用層

**Files:**
- Create: `app/grc/service/import_adapter/__init__.py`
- Create: `app/grc/service/import_adapter/method_keywords.py`
- Modify: `app/grc/service/ap_report_parser/airasia_cmmc_l1_v1.py`（改 import 共用 `_METHOD_KEYWORDS`）
- Test: `test/test_fr044_method_keywords.py`

- [ ] **Step 1: 寫 test**（先鎖定共用行為）

```python
def test_detect_methods_maps_keywords():
    from app.grc.service.import_adapter.method_keywords import detect_methods
    assert "DOCUMENT_REVIEW" in detect_methods("文件檢查：核對員工帳號申請")
    assert "COMPUTER_CONFIG_COMPARE" in detect_methods("電腦設定比對：windows 事件檢視器")
    assert detect_methods("無關文字") == []
```

- [ ] **Step 2: 跑確認 FAIL**（module 不存在）

Run: `pytest test/test_fr044_method_keywords.py -v` → FAIL

- [ ] **Step 3: 建 `method_keywords.py`**：把 `airasia_cmmc_l1_v1.py:93-105` 的 `_METHOD_KEYWORDS` 原樣搬入，加 `detect_methods(text: str) -> list[str]`（任一 keyword 命中即計入、去重、保序）。

- [ ] **Step 4:** `airasia_cmmc_l1_v1.py` 改 `from app.grc.service.import_adapter.method_keywords import _METHOD_KEYWORDS`（刪本地定義），method 偵測邏輯不動。

- [ ] **Step 5: 跑 FR-043 adapter 測試（regression）**

Run: `pytest test/ -k "ap_docx or ap_report or fr043 or airasia" -q`
Expected: 全綠（沿用既有測試；找不到就先確認 FR-043 測試檔名並全跑一次 `pytest test/ -q` 存 baseline）。

- [ ] **Step 6: Commit**

```bash
git add app/grc/service/import_adapter/__init__.py app/grc/service/import_adapter/method_keywords.py \
        app/grc/service/ap_report_parser/airasia_cmmc_l1_v1.py test/test_fr044_method_keywords.py
git commit -m "refactor(FR-044): _METHOD_KEYWORDS 上收 import_adapter 共用層（FR-043 行為不變）"
```

### Task C2: 上收 registry base（loader 可注入）

**Files:**
- Create: `app/grc/service/import_adapter/registry_base.py`
- Modify: `app/grc/service/ap_report_parser/registry.py`（改繼承共用 base，docx loader）
- Test: `test/test_fr044_registry_base.py`

- [ ] **Step 1: 寫 test**：`RegistryBase` 用假 loader + 兩個假 adapter，`parse_file` 命中第一個 `detect()=True`；全不中拋 `UnsupportedFormat(supported_versions)`。

- [ ] **Step 2: 建 `registry_base.py`**

```python
class UnsupportedFormat(Exception):
    def __init__(self, supported_versions):
        self.supported_versions = supported_versions
        super().__init__("no adapter matched")

class RegistryBase:
    """loader(file_path) -> doc；adapters 逐一 detect(doc)。"""
    def __init__(self, adapters, loader):
        self._adapters = adapters
        self._loader = loader
    @property
    def supported_versions(self):
        return [a.version for a in self._adapters]
    def parse_file(self, file_path):
        doc = self._loader(file_path)
        for a in self._adapters:
            if a.detect(doc):
                return a.parse(doc)
        raise UnsupportedFormat(self.supported_versions)
```

- [ ] **Step 3:** `ap_report_parser/registry.py` 改成 `RegistryBase` 的 docx 實例（loader=`docx.Document`），保留 `ApReportUnsupportedFormat` 為 `UnsupportedFormat` 的 alias（避免改 FR-043 app service 的 except 型別）。

- [ ] **Step 4: 跑 FR-043 regression**

Run: `pytest test/ -k "ap_docx or ap_report" -q` → 全綠

- [ ] **Step 5: Commit**

```bash
git add app/grc/service/import_adapter/registry_base.py app/grc/service/ap_report_parser/registry.py test/test_fr044_registry_base.py
git commit -m "refactor(FR-044): registry base 上收共用層（loader 可注入；FR-043 行為不變）"
```

### Task C3: AR 中介結構 + 亞航 xlsx adapter（TDD，樣本 fixture）

**Files:**
- Create: `app/grc/service/ar_report_parser/base.py` — `ParsedArRow`（`row_no, control_ref, objective_seq, objective_text, verdict_raw, method_text, evidence_names[]`）+ `ParsedArRecord`（`adapter_version, framework, meta{project_name, verification_type, headcount, audit_date}, rows[]`）+ `ArReportParserAdapter(ABC)`
- Create: `app/grc/service/ar_report_parser/registry.py`（`RegistryBase(adapters=[AirAsiaCmmcL1ArV1Adapter()], loader=openpyxl 讀取)`）
- Create: `app/grc/service/ar_report_parser/airasia_cmmc_l1_ar_v1.py`
- Test: `test/test_fr044_ar_adapter.py`（樣本 xlsx 進 fixture）

- [ ] **Step 1: 樣本進 fixture**：把 `亞航-CMMC L1內部稽核報告附件-稽核紀錄.xlsx` 複製到 `test/fixtures/fr044/airasia_cmmc_l1_ar.xlsx`（不含機敏；xlsx 表頭專案名等 meta 是測試資料）。

- [ ] **Step 2: 寫 snapshot test**

```python
def test_airasia_ar_adapter_parses_59_rows():
    from app.grc.service.ar_report_parser.airasia_cmmc_l1_ar_v1 import AirAsiaCmmcL1ArV1Adapter
    import openpyxl
    wb = openpyxl.load_workbook("test/fixtures/fr044/airasia_cmmc_l1_ar.xlsx", data_only=True)
    a = AirAsiaCmmcL1ArV1Adapter()
    assert a.detect(wb) is True
    rec = a.parse(wb)
    assert rec.framework == "cmmc_l1"
    assert len(rec.rows) == 59
    # 15 個 AG 區塊、AO 數分佈
    from collections import Counter
    c = Counter(r.control_ref for r in rec.rows)
    assert list(c.values()) == [6,2,6,5,3,3,2,4,6,8,2,6,2,1,3]
    assert rec.meta["audit_date"] == "2026-06-02"   # 2026年6月2日 → ISO
    assert all(r.verdict_raw == "MET" for r in rec.rows)
    # 「(同N)」交叉引用保留原文，解析階段不展開（交給 evidence_matcher）
```

- [ ] **Step 3: 跑確認 FAIL** → 實作 adapter：
  - `detect`：sheet 名含「檢查紀錄」且 row5 表頭含「索引號/Objectives/稽核結果」。
  - `parse`：row1-4 抓 meta（專案名稱 / 受驗證型態 / 受稽核人數 / 稽核日期→ISO，日期解析復用 `airasia_cmmc_l1_v1` 的日期 helper 或搬到 import_adapter 共用）；row6+ 逐列成 `ParsedArRow`；`control_ref` **strip 尾空白**（row12 `AC.L1-b.1.ii ` 有尾空白）；`objective_seq` 從 Objectives 欄的 `[a]`/`[b]` 前綴解；`method_text`=Status 欄；`evidence_names`=Evidence 欄一格多名先拆（換行/逗號/頓號）。

- [ ] **Step 4: 跑測試** → PASS。加邊界 test：一格多名拆分、`[a]` 解析、control_ref 尾空白 normalize。

- [ ] **Step 5: Commit**

```bash
git add app/grc/service/ar_report_parser/ test/test_fr044_ar_adapter.py test/fixtures/fr044/airasia_cmmc_l1_ar.xlsx
git commit -m "feat(FR-044): 亞航 CMMC L1 AR xlsx adapter（59 列 snapshot + meta 解析）"
```

---

## Phase D — framework profile + 對齊 + 配對 + 聚合 + app service（核心）

### Task D1: CMMC L1 framework profile（AG→[NIST] 對照 + AO 對齊校驗）

**Files:**
- Create: `app/grc/service/ar_framework_profile/__init__.py`
- Create: `app/grc/service/ar_framework_profile/cmmc_l1.py`
- Test: `test/test_fr044_framework_profile.py`

- [ ] **Step 1: 寫 test**：對照表 15 鍵；`PE.L1-b.1.ix → [PE.L1-3.10.3, PE.L1-3.10.4, PE.L1-3.10.5]`；verdict 詞彙 `MET→met / NOT MET→not_met / N/A→not_applicable / 空→pending`。

- [ ] **Step 2: 實作**（把 pre-flight 驗證過的對照寫成常數）

```python
FRAMEWORK = "cmmc_l1"
# AG (檢查紀錄) → NIST (catalog) 一對多對照，pre-flight 驗證 AO 數逐條對齊
AG_TO_NIST = {
    "AC.L1-b.1.i":   ["AC.L1-3.1.1"],
    "AC.L1-b.1.ii":  ["AC.L1-3.1.2"],
    "AC.L1-b.1.iii": ["AC.L1-3.1.20"],
    "AC.L1-b.1.iv":  ["AC.L1-3.1.22"],
    "IA.L1-b.1.v":   ["IA.L1-3.5.1"],
    "IA.L1-b.1.vi":  ["IA.L1-3.5.2"],
    "MP.L1-b.1.vii": ["MP.L1-3.8.3"],
    "PE.L1-b.1.viii":["PE.L1-3.10.1"],
    "PE.L1-b.1.ix":  ["PE.L1-3.10.3", "PE.L1-3.10.4", "PE.L1-3.10.5"],
    "SC.L1-b.1.x":   ["SC.L1-3.13.1"],
    "SC.L1-b.1.xi":  ["SC.L1-3.13.5"],
    "SI.L1-b.1.xii": ["SI.L1-3.14.1"],
    "SI.L1-b.1.xiii":["SI.L1-3.14.2"],
    "SI.L1-b.1.xiv": ["SI.L1-3.14.4"],
    "SI.L1-b.1.xv":  ["SI.L1-3.14.5"],
}
def map_verdict(raw: str) -> str:
    t = (raw or "").strip().upper().replace("／", "/")
    if t in ("MET",): return "met"
    if t in ("NOT MET", "NOTMET", "NOT-MET"): return "not_met"
    if t in ("N/A", "NA", "NOT APPLICABLE"): return "not_applicable"
    return "pending"  # 空值 / 無法辨識
```

- [ ] **Step 3: 跑測試** → PASS。Commit。

### Task D2: AO 對齊（AG 區塊 → NIST 控制 AO parts 攤平，Option A 拆分）

**Files:**
- Create: `app/grc/service/ar_import/ao_alignment.py`
- Test: `test/test_fr044_ao_alignment.py`

- [ ] **Step 1: 寫 test**（純函式、無 DB，用假 AO map）

```python
def test_align_splits_one_to_many_by_order():
    # AG PE.L1-b.1.ix 6 列 → NIST [3.10.3(2AO), 3.10.4(1AO), 3.10.5(3AO)]
    # 期望：前2列→3.10.3、第3列→3.10.4、後3列→3.10.5
    from app.grc.service.ar_import.ao_alignment import align_block
    nist_ao_counts = {"PE.L1-3.10.3": 2, "PE.L1-3.10.4": 1, "PE.L1-3.10.5": 3}
    nist_order = ["PE.L1-3.10.3", "PE.L1-3.10.4", "PE.L1-3.10.5"]
    rows = list(range(6))  # 6 個 xlsx 列
    result = align_block(rows, nist_order, nist_ao_counts)
    assert result["PE.L1-3.10.3"] == [0, 1]
    assert result["PE.L1-3.10.4"] == [2]
    assert result["PE.L1-3.10.5"] == [3, 4, 5]

def test_align_count_mismatch_flags_degrade():
    from app.grc.service.ar_import.ao_alignment import align_block, AlignmentMismatch
    # xlsx 5 列 但 NIST AO 總數 6 → 降級
    import pytest
    with pytest.raises(AlignmentMismatch):
        align_block(list(range(5)), ["X"], {"X": 6})
```

- [ ] **Step 2: 實作** `align_block(xlsx_rows, nist_control_ids_in_order, nist_ao_counts) -> dict[nist_id, list[row]]`：
  - 校驗 `sum(nist_ao_counts) == len(xlsx_rows)`，不合拋 `AlignmentMismatch`（caller 整 AG 區塊降級、標 preview 人工核對）。
  - 依 `nist_control_ids_in_order` 順序，各取該控制 AO 數個 xlsx 列。
  - NIST 控制的 AO 數與順序來源＝ `derive_ao_pairs`（caller 傳入解析好的 map，本函式純切分不碰 DB）。

- [ ] **Step 3: 跑測試** → PASS。Commit。

### Task D3: 佐證分級配對

**Files:**
- Create: `app/grc/service/ar_import/evidence_matcher.py`
- Test: `test/test_fr044_evidence_matcher.py`

- [ ] **Step 1: 寫 test**（88 名稱代表案例：精確 / 模糊 / l·I 打字錯 / 無配對 / 「(同8)」交叉引用）

```python
def test_evidence_exact_fuzzy_none():
    from app.grc.service.ar_import.evidence_matcher import match_evidence
    pool = [{"uid":"e1","file_name":"Account_Privilege_Review_Plan.xlsx","description":""},
            {"uid":"e2","file_name":"Windows_Installer.log","description":""}]
    # 精確（去副檔名後全等）
    r1 = match_evidence(["Account_Privilege_Review_Plan"], pool)
    assert r1[0]["level"] == "exact" and r1[0]["matched"][0]["uid"] == "e1"
    # 模糊（Windowslnstaller l/I 打字錯）
    r2 = match_evidence(["Windowslnstaller"], pool)
    assert r2[0]["level"] == "fuzzy"
    # 無配對
    r3 = match_evidence(["完全不存在的檔"], pool)
    assert r3[0]["level"] == "none" and r3[0]["matched"] == []
```

- [ ] **Step 2: 實作**：
  - 正規化：去副檔名 → 去括號註記 → 去空白標點 → lower-case（含 `l/I/1`、`0/O` 常見打字錯的寬鬆比對）。
  - 級別：1 精確（正規化全等）／2 模糊（token 重疊 or 編輯距離達門檻）／3 無配對。
  - 「(同N)」：回傳 marker 讓 caller 解析為第 N 列的佐證集合（本函式只標記，不跨列展開）。
  - pool = 該控制各 AO 的 `job_evidences`（`description` + `upload_files.file_name`）+ surveys，去重同 FE `obsEvidenceOptions`。**caller 組 pool，matcher 純比對**。
  - 原則：寧漏配勿錯配（門檻用 fixture 調）。

- [ ] **Step 3: 跑測試** → PASS。Commit。

### Task D4: 聚合四規則（逐 NIST 控制）

**Files:**
- Create: `app/grc/service/ar_import/aggregation.py`
- Test: `test/test_fr044_aggregation.py`

- [ ] **Step 1: 寫 test**（五組合）

```python
import pytest
from app.grc.service.ar_import.aggregation import aggregate_verdict
@pytest.mark.parametrize("states,expected", [
    (["met","met"], "met"),
    (["met","not_met"], "not_met"),
    (["not_applicable","not_applicable"], "not_applicable"),
    (["met","not_applicable"], "met"),          # met+na 混 → met（CMMC 計分 N/A 視同 MET）
    (["met","pending"], "pending"),
])
def test_aggregate(states, expected):
    assert aggregate_verdict(states) == expected
```

- [ ] **Step 2: 實作**

```python
def aggregate_verdict(states: list[str]) -> str:
    if any(s == "not_met" for s in states): return "not_met"
    if states and all(s == "not_applicable" for s in states): return "not_applicable"
    if any(s == "pending" for s in states): return "pending"
    return "met"  # 全 met 或 met+na 混
```

- [ ] **Step 3: 跑測試** → PASS。Commit。

### Task D5: 擴充 `AssessmentResultAppService`（collected / 多 observation / public gate）

**Files:**
- Modify: `app/grc/service/assessment_result_app_service.py`
- Test: `test/test_fr044_assessment_result_extensions.py`（**記得 logger patch fixture**，見 `feedback_test_logger_patch_db_handler`）

- [ ] **Step 1: 寫 test**：
  - `create_observation(..., collected=<dt>)` → 回傳 obs.collected == 傳入值；不傳 → fallback now。
  - `judge_finding(..., observation_uids=[o1,o2])` → finding.related_observations 含兩者（去重、累加不蓋）。
  - public gate helper `resolve_round_for_import(round_uid, user_id)`：auditor/manager 過、其他 raise ForbiddenError；stage≠auditing raise（既有 assert_round_phase）；無 ar_result raise 412 新碼。

- [ ] **Step 2: 實作 `create_observation` 加 `collected`**

```python
def create_observation(self, round_uid, curr_user, curr_user_id, description,
                       title=None, methods=None, relevant_evidence=None,
                       subjects=None, collected=None, locale=None) -> dict:
    ...
    collected=collected or datetime.now(timezone.utc),
```

- [ ] **Step 3: 實作 `judge_finding` 綁多 observation**：加參數 `observation_uids: Optional[List[str]] = None`；沿用既有單 `observation_uid` 累加邏輯，對 list 逐一 resolve + dedup append 進 `related_observations`。

- [ ] **Step 4: 提 public gate helper**

```python
def resolve_round_for_import(self, round_uid: str, user_id: int):
    """Import 專用守門：auditor/manager + stage=auditing + ar_result 已建。回 round。"""
    r = self._require_round_ar_result(round_uid)          # 含 assert_round_phase auditing + 404 無 ar_result
    self._check_auditor(r.project_id, user_id)            # 既有私有邏輯，經此 public 出口復用
    return r
```
（design 要求「gate 提 public helper」；此法零複製復用既有 `_require_round_ar_result` + `_check_auditor`。註：`_require_round_ar_result` 無 ar_result 時回 404 `GRC_AR_NOT_FOUND` — 若要 import 專屬 412 語意，另加 `resolve_round_for_import` 內先檢 `r.ar_result_id`，缺則 raise `PreconditionFailedError(GRC_AR_ROUND_NOT_AUDITING)`，符合 design §3「未啟動回 412」。）

- [ ] **Step 5: 跑測試** → PASS。**跑既有 AR service 測試 regression**：`pytest test/ -k assessment_result -q` 全綠。

- [ ] **Step 6: Commit**

```bash
git add app/grc/service/assessment_result_app_service.py test/test_fr044_assessment_result_extensions.py
git commit -m "feat(FR-044): AR service 擴充 collected/多 observation 綁定/import gate helper"
```

### Task D6: 錯誤碼

**Files:**
- Modify: `common/code/grc_error_code.py`

- [ ] **Step 1: 加碼**（各 band 從 pre-flight 查到的 max 下一號起；實作前再 grep 一次確認未被占用）

```python
GRC_AR_XLSX_INVALID_FILE        = ("上傳檔案格式無效，請上傳 .xlsx 稽核紀錄檔",        "GRC_400092")
GRC_AR_XLSX_FILE_TOO_LARGE      = ("上傳檔案超過大小限制（10MB）",                    "GRC_400093")
GRC_AR_XLSX_FORMAT_UNSUPPORTED  = ("無法辨識的稽核紀錄格式，目前支援：{versions}",    "GRC_400094")
GRC_AR_XLSX_FRAMEWORK_MISMATCH  = ("檔案框架（{file_fw}）與本輪框架（{round_fw}）不符", "GRC_400095")
GRC_AR_XLSX_PARSE_JOB_NOT_FOUND = ("稽核紀錄解析任務不存在或已被刪除",                "GRC_404047")
GRC_AR_ROUND_NOT_AUDITING       = ("本輪尚未啟動稽核（無判定框架），無法匯入稽核紀錄", "GRC_412043")
GRC_AR_XLSX_PARSE_JOB_NOT_AWAITING = ("稽核紀錄解析任務狀態不是 awaiting_review，無法確認匯入", "GRC_412044")
GRC_AR_XLSX_PARSE_JOB_EXPIRED   = ("稽核紀錄解析任務已過期（24h）",                   "GRC_412045")
```

- [ ] **Step 2: Commit**（與 D7 一起或獨立皆可）

### Task D7: `ArImportAppService` 主流程（parse / preview / confirm / discard）

**Files:**
- Create: `app/grc/service/ar_import_app_service.py`
- Test: `test/test_fr044_ar_import_app_service.py`

**流程要點（mirror `ap_docx_import_app_service.py`）：**
- `upload_and_parse(file, round_uid, user_context)`：`resolve_round_for_import` 守門 → `.xlsx` + 10MB 驗證 → 建 parse job → tempfile + file_upload → `registry.parse_file` → 框架一致性檢查（adapter.framework vs 本輪 AP 框架，不符 raise `GRC_AR_XLSX_FRAMEWORK_MISMATCH`）→ 建 `parsed_result`（見下）→ 持久化。單一 `@transaction`。
- `get_parse_result`：讀 parsed_result + TTL 標記 + 佐證/覆寫警示（讀路徑）。
- `confirm_import`：狀態=awaiting_review + 未過期 → 守門 → **逐 NIST 控制迴圈**：`judge_finding`(聚合判定 + 綁該控制 observations) + 逐 AO `create_observation`(collected + methods + relevant_evidence + description 尾註判定)；not_met 控制 `add_risk`(severity medium) + `link_risk_findings`；單一 `@transaction` 全成全敗。
- `discard_parse`：軟刪。

**`parsed_result` 結構（持久化 JSONB）：**
```
{
  adapter_version, framework,
  meta: {project_name, verification_type, headcount, audit_date},
  overwrite_count: <本輪已有判定的 NIST 控制數（將被覆寫）>,
  degraded_blocks: [<AO 數不一致的 AG id>],
  controls: [   # 依 NIST 控制（17）分組
    { control_id(NIST), source_ag_id, source_row_range,
      aggregated_verdict, will_create_risk: bool,
      observations: [ {objective_seq, description(AO原文+方法+判定尾註),
                       methods:[...], evidence:[{level, names, matched:[{uid,file_name}]}]} ] }
  ]
}
```

- [ ] **Step 1: 寫 app service test**（mock domain services + AR app service；logger patch fixture）
  - parse 樣本 → parsed_result.controls 有 17 組、`PE.L1-3.10.3/4/5` 各自出現且 aggregated_verdict=met、observations 數 = 2/1/3；degraded_blocks 空；overwrite_count 依既有 findings 計。
  - 框架不符 → raise `GRC_AR_XLSX_FRAMEWORK_MISMATCH`。
  - 非 .xlsx / >10MB → 400。
  - confirm 全 MET → judge_finding 呼叫 17 次（met）、create_observation 59 次、add_risk 0 次。
  - 改造樣本（某 AG 區塊有 NOT MET）→ 對應 NIST 控制 aggregated not_met、add_risk severity=medium + link_risk_findings 呼叫、重複 confirm 不重複建 risk（防重見 Step 2）。
  - stage≠auditing / 無 ar_result → 412；TTL 過期 confirm → 412；非 awaiting → 412。

- [ ] **Step 2: 實作**。風險防重（對齊 design §2「同 control＋匯入來源＋open → skip」）：匯入建立的 risk 一律用 title 慣例 `f"{control_id} 未符合"` 當「匯入來源」標記；confirm 前掃本輪 risks，若已存在 `status=open` 且 title==`f"{control_id} 未符合"` 且已連該 NIST finding 的 risk → skip add_risk（避免與使用者手建風險混淆）。

- [ ] **Step 3: 跑測試** → PASS。Commit。

---

## Phase E — route / serializer / DI / FE

### Task E1: serializer + route + DI wiring

**Files:**
- Create: `api/project/serializers/ar_import.py`（Parse / Preview / Confirm schema，mirror `ap_docx_import.py`；Confirm schema 容忍 FE 回送完整 controls，`Meta.unknown=EXCLUDE`）
- Create: `api/project/routes/ar_import_route.py`（Upload / Get+Delete / Confirm，mirror `ap_docx_import_route.py`）
- Modify: `api/project/__init__.py`（register，URL 見 design §3）
- Modify: `di_containers/grc/grc_containers.py`（`ar_xlsx_parse_job_repo/domain_service`、`ar_report_parser_registry`、`ar_import_app_service`；注入 `assessment_result_app_service` + `ssp_control_implementation_service` 供佐證 pool + 控制樹）

- [ ] **Step 1:** 建 serializer + route。URL：
  - `POST /audit-round/<round_uid>/ar-imports/parse`
  - `GET|DELETE /ar-import/<parse_uid>`
  - `POST /ar-import/<parse_uid>/confirm`
- [ ] **Step 2:** DI wiring（parse job Singleton repo + Factory domain service；registry Singleton；app service Factory）。
- [ ] **Step 3: 冒煙**：請 user 重啟 BE 後，Swagger 出現四端點；`GET /ar-import/<不存在>` → 404 `GRC_AR_XLSX_PARSE_JOB_NOT_FOUND`（不 500）。
- [ ] **Step 4: Commit**（route + serializer + DI + error codes 一起）。

### Task E2: FE `ArImportDialog.vue` + 入口按鈕

**Files:**
- Create: `src/components/grc/ar-import/ArImportDialog.vue`（mirror `ap-docx-import/ApDocxImportDialog.vue` 三步 wizard）
- Modify: `src/views/project/RoundAuditReviewView.vue`（header 入口按鈕）
- Modify: `src/config/api/api.js`（`AR_IMPORT_PARSE`/`AR_IMPORT` 常數）
- Modify: i18n（wizard 文案）

- [ ] **Step 1:** api.js 加常數（mirror `AP_DOCX_IMPORT_PARSE`/`AP_DOCX_IMPORT`）：
```js
AR_IMPORT_PARSE: getUrl('/audit-round'),   // + /:roundUid/ar-imports/parse  POST multipart
AR_IMPORT:       getUrl('/ar-import'),      // GET /:uid | POST /:uid/confirm | DELETE /:uid
```
- [ ] **Step 2:** 入口按鈕：`RoundAuditReviewView.vue` header「匯入稽核紀錄」，顯示條件＝`stage=auditing` **且** participant role ∈ {auditor, manager} **且** findings 已建立。**注意**：現有 `canEdit` 只看 stage，**participant role 需另取**（呼叫 participant API 或既有 store；查 `docs/claude/frontend-overview.md` 既有取法，勿新造）。
- [ ] **Step 3:** `ArImportDialog.vue` 三步：
  - Step 1 上傳（.xlsx、10MB）→ 先開 dialog 再 spinner → parse。
  - Step 2 預覽：**依 NIST 控制分組（17）**、可摺疊；每 AO 列＝判定 SelectButton（四態可改）+ 觀察文字（可編）+ 佐證 chips（分級徽章 exact/fuzzy/none + 下拉補勾）+ NOT MET 列「將建立中風險」徽章；頂部摘要（樣板版本 / 框架 / `overwrite_count` 覆寫警示 / `degraded_blocks` 降級清單 + 來源 AG id 標註）；draft 存 localStorage（key by parse_uid）。
  - Step 3 完成：confirm → toast + 摘要（判定 N / 觀察 N / 風險 N / 佐證連結 N）→ refresh findings/stats/risks。
  - 關閉前未確認編輯二次確認。
- [ ] **Step 4:** 手測（見驗收）。Commit（FE repo，顯式 add）。

---

## Phase F — 測試計畫 + E2E

### Task F1: 測試計畫（feature-test-planner agent）

- [ ] **Step 1:** 跑 `feature-test-planner` agent 讀 design.md + 本 plan，產 `docs/features/FR-044-2607-ar-xlsx-import/test-plan.md`（BE pytest 單元/整合 + FE E2E BDD + 追溯矩陣）。
- [ ] **Step 2:** 重點覆蓋（design §7）：adapter snapshot / AO 對齊一對多拆分 + 降級 / 佐證分級 / 聚合五組合 / 權限矩陣×parse-preview-confirm / stage 守門 412 / double-confirm 412 / TTL 412 / confirm 全成全敗 rollback / not_met 建 risk severity=medium + link + 防重 / na 不產 POA&M（finalize 掃描）/ stats na 桶 / **FR-043 regression 全綠**。

### Task F2: E2E（compliance-manager-test repo）

- [ ] **Step 1:** 在 `~/Projects/Billows/Audit-Manager/compliance-manager-test/` 依該 repo CLAUDE.md 寫 BDD：上傳→改判定→確認→稽核頁狀態更新。**不在主專案內加 e2e**。

---

## 驗收基準（亞航樣本，對照 design §5 + Option A 修正）

上傳樣本 xlsx 後：
- preview：**17 個 NIST 控制**分組（`PE.L1-b.1.ix` 拆成 `PE.L1-3.10.3/4/5` 三組，各標來源 AG id）全部聚合為 **met**（樣本 59 列全 MET）；59 筆 observation 預覽（各帶 AO 原文＋方法＋判定尾註）；佐證高信心自動勾選（模糊帶徽章、無配對灰列）；無風險建立。
- confirm 後稽核執行頁：**17 控制項判定＝met**（左側清單本就 17 條）、observations 掛對控制項、stats 正確（met=17、na=0、pending=0）、`finalize_assessment` 可正常推進（不被 pending / risk-no-finding 擋）。
- 改造樣本（改幾列 NOT MET／N/A／留空）驗：聚合四規則（含 `PE.L1-b.1.ix` 拆分後只有被判 not_met 的那條 NIST 控制建風險）＋中風險自動建立＋`link_risk_findings` 已連＋重複匯入 risk 防重＋na 不產 POA&M。

> **與 design §5 的差異說明**：design 寫「15 個控制項」，實際系統粒度為 **17 個 NIST finding**（`PE.L1-b.1.ix` 一對多）。preview 以 NIST 分組並標註來源 AG，confirm 寫 17 筆。此為 Option A（user 2026-07-03 拍板）。

---

## 實作紀律（全程）

- `@transaction` 只在 app service public method；repo lazy session；route 不碰 DB；權限 app service 層 fail-closed。
- 顯式 `git add <檔名>`、禁 `-am`；push 等 user；不切 branch。
- SQL migration 用 `cmmgr`、`-p 25432`、`--single-transaction -v ON_ERROR_STOP=1`、先套 DEV、收尾補 STG/POC。
- 禁重複造輪子：寫任何新 method 前先 grep 既有行為（本 plan 的擴充皆為既有方法加參數，非新造）。
- app service test 記得 logger patch fixture；jedi_oscal_v2 dev 走 path dependency、不自行發版。
- 收尾動作（changelog / SUMMARY / Notion / memory）等 user 明示才做。
