# FR-043 稽核計畫 docx 匯入 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 / 稽核單位在 ap-authoring 頁上傳顧問的稽核報告 docx，系統解析成行程預填、預覽修改後確認寫入 AP（走既有 `set_tasks` / `add_ap_party`）。

**Architecture:** 全面 mirror SSP docx 匯入三段式管線（parse → preview → confirm），新增 `oscal.ap_docx_parse_jobs` 表承載暫存解析結果（TTL 24h），parser 採規則式 adapter registry（本期只有亞航 CMMC L1 v1），confirm 不另開 write path —— 組好 `_TaskItem` payload 後呼叫既有 `AssessmentPlanAppService.set_tasks()`（append 語意）與 `add_ap_party()`（同名走配對）。整個 confirm 在單一 `@transaction`（已驗證 reentrant）內，失敗整筆 rollback。

**Tech Stack:** Python 3.11 / Flask-RESTful (MethodResource) / marshmallow / SQLAlchemy 2.x (Mapped) / dependency-injector / python-docx / pytest；FE Vue 3 Composition API + PrimeVue 3.53。

---

## 0. Pre-flight 結論（開工前提，已於 plan 撰寫階段驗證）

- `set_tasks` / `add_ap_party` 簽名、`_TaskItem` 形狀、`ssp_docx_parse_job` 表/entity/repo、`_DOCX_MAX_SIZE`(20MB) / `_PARSE_JOB_TTL_HOURS`(24h)、樣本 docx 全部與 design.md 一致 ✅
- `@transaction` **reentrant**（jedi_common `db.py:106`：已在 transaction 內則直接執行、不再開 scope/不中途 commit）→ confirm 呼叫 `set_tasks`/`add_ap_party` 為單一原子交易 ✅
- `add_ap_party` 對同名 party `raise ConflictError`（非 idempotent）→ confirm 內同名一律視為「配對既有」，不呼叫 `add_ap_party` ✅
- **spec 措辭修正**（不動 design 主結論）：樣本 docx 章節標題是**無編號的 `[Normal]` 段落**（依據 / 目的 / 稽核單位 / 稽核日期 / 稽核範圍 / 稽核方法 / 稽核結果），parser 以**標題字串**辨識章節，非樣式或編號。
- **jedi-\* 零異動**。實作中若發現非動不可 → 停下回報。

## 1. 決策紀錄（實作時照此，不重新發明）

| 決策 | 選擇 | 理由 |
|---|---|---|
| parse job 表欄位 | 精簡版（**不含** `mode` / `framework` NOT NULL） | AP parser 靠 adapter registry 自動偵測格式，`mode`/`framework` 對 AP 無意義；避免死欄位（CLAUDE.md 禁 dead column） |
| 模組歸屬 | entity/domain/infra 放 **grc** 模組（`domain/grc/`、`infra/grc/`），table 落 `oscal` schema | app service 在 `app/grc/`，cohesion 跟著 app service 走；schema ≠ module（table 與 sibling parse job 同放 oscal schema，符合 design §4.1） |
| DI 掛載 | `GrcContainer`（`di_containers/grc/grc_containers.py`） | 與被復用的 `assessment_plan_app_service` 同容器，直接注入 |
| gate 復用 | 把 `AssessmentPlanAppService._resolve_ap_and_check_auditor` 改名為 public `resolve_ap_and_check_auditor`（更新 4 處內部 call site）＋ 新增 public `resolve_ap_and_check_participant`（preview 用，任一 participant） | design §4.2「提為 public helper，不跨 service 呼叫私有、不複刻條件」 |
| confirm 寫入 | 呼叫既有 `add_ap_party`（新 party）＋ `set_tasks`（append） | design §4.1「禁止重複造輪子」 |
| append 轉換 | confirm 內讀既有 `_ap_detail(ap)["tasks"]` → 轉成 `_TaskItem` shape → 串接新行程 → 全量覆寫 | `_ap_detail` 讀 shape ≠ `_TaskItem` 寫 shape（design §4.1） |
| 錯誤碼 | 復用既有 job/phase/auth 碼；只新增 2 個 AP 專用 400（檔案過大 20MB、格式無法辨識） | design §4.3 |
| parse job 持久化欄位 | `parsed_result` = `ParsedApReport` 的 dict（含 `adapter_version` / suggested payload / party 配對建議） | 單一 JSONB 承載，preview 讀它組 response |

## 2. File Structure

**BE（`compliance-manager-be`）**

| 動作 | 路徑 | 職責 |
|---|---|---|
| Create | `scripts/sql/2026-07-02-ap-docx-parse-jobs.sql` | 建 `oscal.ap_docx_parse_jobs` + index + RLS + GRANT cm_app + schema_migrations |
| Create | `domain/grc/entities/ap_docx_parse_job_entity.py` | `ApDocxParseJobEntity` + `ApDocxParseJobQueryEntity` |
| Create | `domain/grc/repository/i_ap_docx_parse_job_repo.py` | repo interface |
| Create | `domain/grc/service/ap_docx_parse_job_domain_service.py` | `ApDocxParseJobDomainService`（create / get_one / update_status / write_parsed_result / write_error / write_import_summary / deactivate） |
| Create | `infra/grc/model/ap_docx_parse_job.py` | ORM model（`__table_args__ = {"schema": "oscal"}`） |
| Create | `infra/grc/mapper/ap_docx_parse_job_mapper.py` | entity ↔ model mapper |
| Create | `infra/grc/repository/ap_docx_parse_job_repo_impl.py` | `BaseRepositoryImpl` 子類（含 `update`/`deactivate` override，mirror ssp 版） |
| Create | `app/grc/service/ap_report_parser/__init__.py` | export registry + adapter base + `ParsedApReport` |
| Create | `app/grc/service/ap_report_parser/base.py` | `ParsedApReport` dataclass + `ApReportParserAdapter` ABC |
| Create | `app/grc/service/ap_report_parser/registry.py` | `ApReportParserRegistry`（依序 detect → 第一命中 parse） |
| Create | `app/grc/service/ap_report_parser/airasia_cmmc_l1_v1.py` | 亞航 CMMC L1 v1 adapter（header-label 章節切分） |
| Create | `app/grc/service/ap_docx_import_app_service.py` | `ApDocxImportAppService`（upload_and_parse / get_parse_result / discard_parse / confirm_import） |
| Create | `api/project/serializers/ap_docx_import.py` | Parse / Preview / Confirm schema |
| Create | `api/project/routes/ap_docx_import_route.py` | 4 個 MethodResource（upload / get+delete / confirm） |
| Modify | `api/project/__init__.py` | 註冊 4 條 route |
| Modify | `app/grc/service/assessment_plan_app_service.py` | 私有 gate → public ×2；4 處 call site 改名 |
| Modify | `common/code/grc_error_code.py` | +2 個 AP docx 400 碼 |
| Modify | `di_containers/grc/grc_containers.py` | wire parse-job repo/domain + adapter registry + app service；**加 `upload_file_container` DependenciesContainer** |
| Modify | `di_containers/containers.py` | root `grc_container` block **傳入 `upload_file_container=upload_file_container`**（grc 目前拿不到 file_upload_service，見 A7） |
| Create | `test/fixtures/ap_docx/airasia_cmmc_l1_sample.docx` | 樣本 docx（從桌面複製，去識別化非必要—內部測試） |
| Create | `test/test_ap_report_parser_airasia.py` | parser snapshot 測試 |
| Create | `test/test_ap_docx_import_app_service.py` | app service 權限矩陣 / TTL / append / 同名 party |

**FE（`compliance-manager-fe`，Phase D）**

| 動作 | 路徑 | 職責 |
|---|---|---|
| Create | `src/components/grc/ap-docx-import/ApDocxImportDialog.vue` | 三步 wizard dialog |
| Create | `src/composables/useApDocxDraft.js` | localStorage draft（mirror `useSspDocxDraft`，key by parse_uid） |
| Create | `src/service/ApDocxImportService.js` | api 呼叫封裝（繼承 BaseService） |
| Modify | `src/config/api/api.js` | +AP docx import 端點常數 |
| Modify | `src/views/project/RoundApAuthoringView.vue` | header「匯入稽核計畫」按鈕 + 掛 dialog + 成功後 refresh |
| Modify | FE i18n（`zh_Hant_TW` / `en`） | 對話框文案 + 新 error code 文案 |

**測試專案（`compliance-manager-test`，Phase E）**：由 `feature-test-planner` 產 `test-plan.md`，e2e feature/step/page 一律在此 repo。

---

## Phase A — BE 基建（parse job 表 + entity/model/mapper/repo/domain + DI）

### Task A1: SQL migration — `oscal.ap_docx_parse_jobs`

**Files:**
- Create: `scripts/sql/2026-07-02-ap-docx-parse-jobs.sql`

- [ ] **Step 1: 寫 migration**（每語句加日期註解；GRANT cm_app + sequence；結尾 INSERT schema_migrations）

```sql
-- Date: 2026-07-02
-- =============================================================================
-- FR-043 稽核計畫 docx 匯入 — ap_docx_parse_jobs 表
-- 1. 新建 oscal.ap_docx_parse_jobs（AP docx 解析工作記錄，TTL 24h）
-- 2. index（source / status / tenant）
-- 3. RLS + policy
-- 4. GRANT cm_app（表 + sequence）
-- 5. INSERT schema_migrations
-- =============================================================================

-- 1. 建表 (2026-07-02)
CREATE TABLE oscal.ap_docx_parse_jobs (
    id             SERIAL PRIMARY KEY,
    uid            VARCHAR(36) NOT NULL UNIQUE,                 -- UUIDv4（= parse_uid）
    tenant_id      INTEGER NOT NULL,                            -- RLS
    source_type    VARCHAR(20) NOT NULL DEFAULT 'ap',           -- 固定 'ap'
    source_uid     VARCHAR(36) NOT NULL,                        -- ap.uid
    status         VARCHAR(20) NOT NULL DEFAULT 'pending',       -- pending|awaiting_review|completed|failed|expired|discarded
    file_path      VARCHAR(255),                                 -- file upload uid
    file_name      VARCHAR(255),
    file_size      BIGINT,
    parsed_result  JSONB,                                        -- ParsedApReport + suggested payload + party 配對建議
    error_code     VARCHAR(40),
    error_message  TEXT,
    import_summary JSONB,                                        -- confirm 後寫入
    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
);

-- 2. index (2026-07-02)
CREATE INDEX idx_ap_docx_parse_jobs_source ON oscal.ap_docx_parse_jobs(source_type, source_uid);
CREATE INDEX idx_ap_docx_parse_jobs_status ON oscal.ap_docx_parse_jobs(status, created_at);
CREATE INDEX idx_ap_docx_parse_jobs_tenant ON oscal.ap_docx_parse_jobs(tenant_id);

-- 3. RLS (2026-07-02)
ALTER TABLE oscal.ap_docx_parse_jobs ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_ap_docx_parse_jobs ON oscal.ap_docx_parse_jobs
    USING (tenant_id::text = current_setting('app.user_id', true)
        OR current_setting('app.allowed_tenant_paths', true) LIKE '%' || tenant_id::text || '%');

-- 4. GRANT cm_app (2026-07-02)
GRANT SELECT, INSERT, UPDATE, DELETE ON oscal.ap_docx_parse_jobs TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE oscal.ap_docx_parse_jobs_id_seq TO cm_app;

-- 5. schema_migrations (2026-07-02) — 真實表為 (filename, note) PK，非 version
INSERT INTO public.schema_migrations(filename, note) VALUES
  ('2026-07-02-ap-docx-parse-jobs.sql', 'FR-043 Task A1 / oscal.ap_docx_parse_jobs')
ON CONFLICT (filename) DO NOTHING;
```
> ✅ A1 已完成（commit `bb9e24f`）：已套 DEV、RLS 驗證通過。

- [ ] **Step 2: 套 DEV**（`cmmgr`，port 25432，密碼查 `.env` `DB_SECRET`）

Run:
```bash
PGPASSWORD='<查 .env>' 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-02-ap-docx-parse-jobs.sql
```
Expected: `CREATE TABLE` … `GRANT` … `INSERT 0 1`，無 error。

- [ ] **Step 3: 驗證表存在 + cm_app 可讀**

Run:
```bash
PGPASSWORD='<查 .env>' psql -h 192.168.50.188 -p 25432 -U cm_app -d guidant_ai_dev -c \
  "SELECT count(*) FROM oscal.ap_docx_parse_jobs;"
```
Expected: `0`（cm_app 有權限、RLS 通）。

- [ ] **Step 4: Commit**
```bash
git add scripts/sql/2026-07-02-ap-docx-parse-jobs.sql
git commit -m "feat(FR-043): ap_docx_parse_jobs 表 migration（RLS + GRANT cm_app）"
```

> STG / POC / PROD 待整個 feature 完成、user 指示後再套（比照專案慣例，dev 先行）。

### Task A2: Entity + Query

**Files:**
- Create: `domain/grc/entities/ap_docx_parse_job_entity.py`

- [ ] **Step 1: 寫 entity**（照 `SspDocxParseJobEntity` 但去掉 `mode`/`framework`）

```python
from datetime import datetime
from typing import Optional


class ApDocxParseJobEntity:
    """AP docx 解析任務 Domain Entity（FR-043）。"""

    def __init__(
        self,
        id: Optional[int] = None,
        uid: Optional[str] = None,
        tenant_id: Optional[int] = None,
        source_type: Optional[str] = "ap",
        source_uid: Optional[str] = None,      # ap.uid
        status: Optional[str] = None,
        file_path: Optional[str] = None,
        file_name: Optional[str] = None,
        file_size: Optional[int] = None,
        parsed_result: Optional[dict] = None,
        error_code: Optional[str] = None,
        error_message: Optional[str] = None,
        import_summary: Optional[dict] = None,
        is_active: bool = True,
        created_at: Optional[datetime] = None,
        created_user: Optional[str] = None,
        updated_at: Optional[datetime] = None,
        updated_user: Optional[str] = None,
    ):
        self.id = id
        self.uid = uid
        self.tenant_id = tenant_id
        self.source_type = source_type
        self.source_uid = source_uid
        self.status = status
        self.file_path = file_path
        self.file_name = file_name
        self.file_size = file_size
        self.parsed_result = parsed_result
        self.error_code = error_code
        self.error_message = error_message
        self.import_summary = import_summary
        self.is_active = is_active
        self.created_at = created_at
        self.created_user = created_user
        self.updated_at = updated_at
        self.updated_user = updated_user


class ApDocxParseJobQueryEntity:
    def __init__(
        self,
        uid: Optional[str] = None,
        tenant_id: Optional[int] = None,
        source_type: Optional[str] = None,
        source_uid: Optional[str] = None,
        status: Optional[str] = None,
        is_active: Optional[bool] = True,
    ):
        self.uid = uid
        self.tenant_id = tenant_id
        self.source_type = source_type
        self.source_uid = source_uid
        self.status = status
        self.is_active = is_active
```

- [ ] **Step 2: Commit**（與 A3/A4/A5 可合併一個 commit，見 A6）

### Task A3: ORM Model

**Files:**
- Create: `infra/grc/model/ap_docx_parse_job.py`

- [ ] **Step 1: 寫 model**（`__table_args__` schema=oscal；欄位對齊 migration）

```python
from typing import Optional

from sqlalchemy import BigInteger, Boolean, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

from jedi_common.session.database.model.base_model import BaseModel
from jedi_common.session.database.model.tenant_mixin_model import TenantScopedMixinModel


class ApDocxParseJob(BaseModel, TenantScopedMixinModel):
    """AP docx 解析工作記錄（FR-043）。"""

    __tablename__ = "ap_docx_parse_jobs"
    __table_args__ = {"schema": "oscal"}

    uid: Mapped[str] = mapped_column(String(36), unique=True, nullable=False)
    source_type: Mapped[str] = mapped_column(String(20), nullable=False, default="ap")
    source_uid: Mapped[str] = mapped_column(String(36), nullable=False)
    status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
    file_path: Mapped[Optional[str]] = mapped_column(String(255))
    file_name: Mapped[Optional[str]] = mapped_column(String(255))
    file_size: Mapped[Optional[int]] = mapped_column(BigInteger)
    parsed_result: Mapped[Optional[dict]] = mapped_column(JSONB)
    error_code: Mapped[Optional[str]] = mapped_column(String(40))
    error_message: Mapped[Optional[str]] = mapped_column(Text)
    import_summary: Mapped[Optional[dict]] = mapped_column(JSONB)
    is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
```

> 注意：`tenant_id` / `created_at` / `created_user` / `updated_at` / `updated_user` 由 `BaseModel` + `TenantScopedMixinModel` 提供。實作時**先 Read `SspDocxParseJob` model 確認 mixin 是否已含 `tenant_id`**（ssp 版沒有另宣告 `tenant_id`）。

### Task A4: Mapper

**Files:**
- Create: `infra/grc/mapper/ap_docx_parse_job_mapper.py`

- [ ] **Step 1: 先 Read `infra/oscal/mapper/ssp_docx_parse_job_mapper.py`** 確認 mapper base 型別與 `to_entity`/`to_model` 慣例，照抄形狀（去掉 mode/framework 欄位）。

### Task A5: Repo interface + impl

**Files:**
- Create: `domain/grc/repository/i_ap_docx_parse_job_repo.py`
- Create: `infra/grc/repository/ap_docx_parse_job_repo_impl.py`

- [ ] **Step 1: interface**（mirror `ISspDocxParseJobRepo`：`get_one` / `update(id, fields, user)` / `deactivate(uid, user)`；`add` 由 base 提供）
- [ ] **Step 2: impl**（`BaseRepositoryImpl[Entity, Query, Model, Mapper]`，`__init__` 只 `super().__init__(mapper=…, model=…)`，**不碰 session**；`update`/`deactivate` override 照 ssp 版，含 `_PROTECTED_FIELDS`）

### Task A6: Domain service

**Files:**
- Create: `domain/grc/service/ap_docx_parse_job_domain_service.py`

- [ ] **Step 1: 寫 domain service**（照 `SspDocxParseJobDomainService`，`create` 去 mode/framework）

```python
import uuid
from typing import Optional

from domain.grc.entity.ap_docx_parse_job_entity import (
    ApDocxParseJobEntity,
    ApDocxParseJobQueryEntity,
)
from domain.grc.repository.i_ap_docx_parse_job_repo import IApDocxParseJobRepo


class ApDocxParseJobDomainService:
    """AP docx 解析任務 domain service（FR-043）。"""

    def __init__(self, repo: IApDocxParseJobRepo):
        self._repo = repo

    def create(self, source_uid: str, file_name: str, file_size: int,
               tenant_id: int, user: str) -> ApDocxParseJobEntity:
        return self._repo.add(ApDocxParseJobEntity(
            uid=str(uuid.uuid4()), tenant_id=tenant_id,
            source_type="ap", source_uid=source_uid,
            file_name=file_name, file_size=file_size, status="pending",
            created_user=user, updated_user=user,
        ))

    def get_one(self, uid: str) -> Optional[ApDocxParseJobEntity]:
        return self._repo.get_one(ApDocxParseJobQueryEntity(uid=uid))

    def update_status(self, uid: str, status: str, user: str, **fields):
        job = self.get_one(uid)
        if not job:
            return None
        return self._repo.update(job.id, {"status": status, **fields}, user)

    def write_parsed_result(self, uid: str, parsed_result: dict, user: str):
        return self.update_status(uid, "awaiting_review", user, parsed_result=parsed_result)

    def write_error(self, uid: str, error_code: str, error_message: str, user: str):
        return self.update_status(uid, "failed", user,
                                  error_code=error_code, error_message=error_message)

    def write_import_summary(self, uid: str, summary: dict, user: str):
        return self.update_status(uid, "completed", user, import_summary=summary)

    def deactivate(self, uid: str, user: str) -> None:
        self._repo.deactivate(uid, user)
```

- [ ] **Step 2: import 冒煙**

Run:
```bash
set -a; source .env; set +a
python -c "from domain.grc.service.ap_docx_parse_job_domain_service import ApDocxParseJobDomainService; \
from infra.grc.repository.ap_docx_parse_job_repo_impl import ApDocxParseJobRepoImpl; \
from infra.grc.model.ap_docx_parse_job import ApDocxParseJob; print('ok')"
```
Expected: `ok`（無 import error / SQLAlchemy mapper error）。

- [ ] **Step 3: Commit**
```bash
git add domain/grc/entity/ap_docx_parse_job_entity.py \
        domain/grc/repository/i_ap_docx_parse_job_repo.py \
        domain/grc/service/ap_docx_parse_job_domain_service.py \
        infra/grc/model/ap_docx_parse_job.py \
        infra/grc/mapper/ap_docx_parse_job_mapper.py \
        infra/grc/repository/ap_docx_parse_job_repo_impl.py
git commit -m "feat(FR-043): ap_docx_parse_job entity/model/mapper/repo/domain service"
```

### Task A7: DI wiring

**Files:**
- Modify: `di_containers/grc/grc_containers.py`

- [ ] **Step 1: 先 Read grc_containers.py import 區 + `GrcContainer` 上半**，確認 provider 宣告慣例與 `auth_container` / `oscal_container` 等 DependenciesContainer 可用性。
- [ ] **Step 2: 加 provider**（parse-job repo Singleton → domain Factory；app service 見 Phase C 一起 wire，因需 parser/registry/file_upload）。此步先只加 parse-job repo + domain：

```python
from infra.grc.repository.ap_docx_parse_job_repo_impl import ApDocxParseJobRepoImpl
from domain.grc.service.ap_docx_parse_job_domain_service import ApDocxParseJobDomainService
# ...
ap_docx_parse_job_repo = providers.Singleton(ApDocxParseJobRepoImpl)
ap_docx_parse_job_domain_service = providers.Factory(
    ApDocxParseJobDomainService, repo=ap_docx_parse_job_repo,
)
```

- [ ] **Step 3: 補 `upload_file_container` 到 grc（必做，已驗證 grc 目前拿不到 file_upload_service）**。GrcContainer 宣告的 13 個 DependenciesContainer **不含** `upload_file_container`，root `containers.py` 的 `grc_container` block 也沒傳 → 兩處都要改（mirror oscal/module_frame）：
  1. `di_containers/grc/grc_containers.py`：`GrcContainer` 加 `upload_file_container = providers.DependenciesContainer()`
  2. `di_containers/containers.py`（grc_container `providers.Container(...)` block，~line 248）：加 `upload_file_container=upload_file_container,`
  之後 Phase C app service provider 用 `upload_file_container.file_upload_service`。

---

## Phase B — parser（adapter registry + 亞航 v1，TDD）

### Task B1: 樣本 fixture + `ParsedApReport` / adapter base

**Files:**
- Create: `test/fixtures/ap_docx/airasia_cmmc_l1_sample.docx`（`cp` 桌面樣本）
- Create: `app/grc/service/ap_report_parser/base.py`

- [ ] **Step 1: 複製樣本**
```bash
mkdir -p test/fixtures/ap_docx
cp "/Users/chouraymond/Desktop/AirAsia實際稽核檔案/AP稽核計畫匯入測試檔案/亞航-CMMC L1內部稽核報告20260612(稿).docx" \
   test/fixtures/ap_docx/airasia_cmmc_l1_sample.docx
```

- [ ] **Step 2: 寫 base**

```python
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Dict, List, Optional


@dataclass
class ParsedApReport:
    """版本無關中間結構（adapter 產出 → app service 組 payload 只依賴這個）。"""
    adapter_version: str
    title: Optional[str] = None
    audit_date: Optional[str] = None          # ISO date "2026-06-02"（無法解析則 None）
    audit_date_text: Optional[str] = None      # 時段全文（"上午10:00-12:30"）→ 進備註
    audit_org: Optional[str] = None            # "鈊安資安顧問"
    scope_text: Optional[str] = None
    methods: List[str] = field(default_factory=list)             # AUDIT_METHOD value（見 B3）
    method_guidances: Dict[str, str] = field(default_factory=dict)  # {method_value: 查核指引}
    basis_text: Optional[str] = None           # §依據 全文
    objective_text: Optional[str] = None        # §目的 全文
    raw_sections: Dict[str, str] = field(default_factory=dict)   # {header: full text}（除錯/未來擴充）


class ApReportParserAdapter(ABC):
    version: str = ""

    @abstractmethod
    def detect(self, doc) -> bool:
        """章節結構特徵偵測（header-label 命中）。"""

    @abstractmethod
    def parse(self, doc) -> ParsedApReport:
        """解析成版本無關中間結構。"""
```

### Task B2: registry

**Files:**
- Create: `app/grc/service/ap_report_parser/registry.py`
- Create: `app/grc/service/ap_report_parser/__init__.py`

- [ ] **Step 1: registry**

```python
from typing import List
import docx  # python-docx

from app.grc.service.ap_report_parser.base import ApReportParserAdapter, ParsedApReport


class ApReportUnsupportedFormat(Exception):
    """全 adapter miss。app service 捕捉 → 400 格式無法辨識 + 支援版本清單。"""
    def __init__(self, supported_versions: List[str]):
        self.supported_versions = supported_versions
        super().__init__("no adapter matched")


class ApReportParserRegistry:
    def __init__(self, adapters: List[ApReportParserAdapter]):
        self._adapters = adapters

    @property
    def supported_versions(self) -> List[str]:
        return [a.version for a in self._adapters]

    def parse_file(self, file_path: str) -> ParsedApReport:
        doc = docx.Document(file_path)
        for adapter in self._adapters:
            if adapter.detect(doc):
                return adapter.parse(doc)
        raise ApReportUnsupportedFormat(self.supported_versions)
```

- [ ] **Step 2: `__init__.py`** re-export `ParsedApReport` / `ApReportParserAdapter` / `ApReportParserRegistry` / `ApReportUnsupportedFormat`。

### Task B3: 亞航 CMMC L1 v1 adapter（TDD）

**Files:**
- Create: `test/test_ap_report_parser_airasia.py`
- Create: `app/grc/service/ap_report_parser/airasia_cmmc_l1_v1.py`

> **AUDIT_METHOD value 對照（開工前先確認）**：design §3 說 methods 對照 `system_menu` `AUDIT_METHOD`。程式碼未 grep 到常數，值存 DB `system_menu`。**Step 0（開工先做）**：
> ```bash
> PGPASSWORD='<查 .env>' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c \
>   "SELECT code, name FROM public.system_menu WHERE group_code ILIKE '%method%' OR code ILIKE '%interview%';"
> ```
> 用實際 `code`（如 `INTERVIEW`/`EXAMINE`/`TEST`）當 `methods` value，別自創字串。若查無 → 停下回報 user（design 假設的 menu 不存在）。

- [ ] **Step 1: 寫 failing 測試**（snapshot 對 §3 對應表；期望值以 pre-flight 讀到的樣本內容為準）

```python
import os
import pytest
from app.grc.service.ap_report_parser.airasia_cmmc_l1_v1 import AirAsiaCmmcL1V1Adapter
import docx

FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures/ap_docx/airasia_cmmc_l1_sample.docx")


@pytest.fixture
def parsed():
    adapter = AirAsiaCmmcL1V1Adapter()
    doc = docx.Document(FIXTURE)
    assert adapter.detect(doc) is True
    return adapter.parse(doc)


def test_title(parsed):
    assert "CMMC 2.0 Level 1內部稽核報告" in parsed.title

def test_audit_date(parsed):
    assert parsed.audit_date == "2026-06-02"
    assert "10:00" in (parsed.audit_date_text or "")

def test_audit_org(parsed):
    assert parsed.audit_org == "鈊安資安顧問"

def test_scope_text(parsed):
    assert "屏東軍機性能提升案" in parsed.scope_text
    assert "AACL-CMMC-SSP-01" in parsed.scope_text

def test_methods(parsed):
    # value 對照 system_menu AUDIT_METHOD（Step 0 查到的實際 code）
    assert set(parsed.methods) >= {"INTERVIEW", "EXAMINE", "TEST"}

def test_method_guidances(parsed):
    # §稽核方法 敘述拆出的每方法查核指引（至少每個 method 有非空指引）
    for m in parsed.methods:
        assert parsed.method_guidances.get(m)

def test_basis_and_objective(parsed):
    assert "CMMC" in parsed.basis_text
    assert "DoW" in parsed.objective_text or "供應鏈" in parsed.objective_text
```

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

Run: `set -a; source .env; set +a; pytest test/test_ap_report_parser_airasia.py -v`
Expected: FAIL（module not found / adapter 未實作）。

- [ ] **Step 3: 實作 adapter**（要點，非逐行）
  - `detect`：走 `doc.paragraphs`，收集非空段落文字；命中判準 = 同時出現「依據」「目的」「稽核單位」「稽核日期」「稽核範圍」「稽核方法」這組 header-label（≥5 命中）→ True。
  - `parse`：以 header-label 段落當切分點，把「該 header 之後、下一 header 之前」的段落聚成 section 文字，存 `raw_sections`；再逐 section 抽值：
    - 標題 = 第一個非空段落
    - `稽核日期`：正則抽 `(\d{4})年(\d{2})月(\d{2})日` → ISO；剩餘時段字串進 `audit_date_text`
    - `稽核單位`：去「由…協助辦理」殼取組織名（regex `由(.+?)(協助|辦理|$)`；fallback 整句）
    - `稽核範圍`：section 全文 → `scope_text`
    - `稽核方法`：關鍵詞比對（訪談/Interview→INTERVIEW；文件與政策紀錄檢查/現場實地觀察/Examination→EXAMINE；實體電腦組態設定比對/Technical Configuration Review/Test→TEST）→ `methods`；把描述該方法用途的整句填 `method_guidances[method]`（同一句涵蓋多方法時各自掛一份）
    - `依據` / `目的`：section 全文 → `basis_text` / `objective_text`
  - `稽核結果`、`附件` section 忽略（AR 範疇，見 analysis §2.3）。
  - 缺 section → 對應欄位 None / 空 list，不丟例外（丟例外的只有 detect 全 miss，由 registry 處理）。

- [ ] **Step 4: 跑測試確認 pass**

Run: `pytest test/test_ap_report_parser_airasia.py -v`
Expected: PASS（全 8 test）。

- [ ] **Step 5: 加一個 negative 測試**（非目標格式 → detect False）—— 用一個只有一段 "hello" 的臨時 docx（測試內 `docx.Document()` 動態建）→ `detect` False。

- [ ] **Step 6: Commit**
```bash
git add test/fixtures/ap_docx/airasia_cmmc_l1_sample.docx test/test_ap_report_parser_airasia.py \
        app/grc/service/ap_report_parser/
git commit -m "feat(FR-043): AP 報告 parser adapter registry + 亞航 CMMC L1 v1（TDD）"
```

---

## Phase C — app service + serializer + route + error code

### Task C1: 錯誤碼

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

- [ ] **Step 1: 加 2 個 AP docx 400 碼**（序號取未用的；建議 `GRC_400090` / `GRC_400091`，加前先 `grep GRC_40009 common/code/grc_error_code.py` 確認未用）

```python
GRC_AP_DOCX_FILE_TOO_LARGE   = ("上傳檔案超過大小限制（20MB）", "GRC_400090")
GRC_AP_DOCX_FORMAT_UNSUPPORTED = ("無法辨識的稽核報告格式，目前支援：{versions}", "GRC_400091")
```

> 復用既有碼：`GRC_DOCX_INVALID_FILE`(400005，非 .docx)、`GRC_AP_NOT_FOUND`(404015)、`GRC_DOCX_PARSE_JOB_NOT_FOUND`(404023)、`GRC_DOCX_PARSE_JOB_NOT_AWAITING`(412012)、`GRC_DOCX_PARSE_JOB_EXPIRED`(412013)、`GRC_DOCX_PARSE_FAILED`(422003)、`GRC_NOT_AUDITOR`(權限)、phase gate 既有 412。

### Task C2: gate helper 提為 public

**Files:**
- Modify: `app/grc/service/assessment_plan_app_service.py`

- [ ] **Step 1: 改名** `_resolve_ap_and_check_auditor` → `resolve_ap_and_check_auditor`（去底線），更新 **4 處**內部 call site（**420** `set_reviewed_controls` / **432** `set_assessment_subjects` / **463** `set_tasks` / **600** `add_ap_party`）+ 定義處。**漏改 600 會讓 `add_ap_party` 執行期 `AttributeError`，而 confirm 會呼叫 `add_ap_party` → 直接炸本 feature**。改完 `grep -n "_resolve_ap_and_check_auditor\|resolve_ap_and_check_auditor" app/grc/service/assessment_plan_app_service.py` 確認舊名 0 筆。
- [ ] **Step 2: 新增 public** `resolve_ap_and_check_participant(ap_uid, curr_user_id)`（preview 用；驗 AP 存在 + round 存在 + user 是該 project **任一** participant，不卡 role、**不**卡 phase）：

```python
def resolve_ap_and_check_participant(self, ap_uid: str, curr_user_id: int):
    """preview 守門：AP 存在 + user 是該 project participant（任一角色，不卡階段）。
    caller 必須在 @transaction scope 內。回傳 ApEntity。"""
    ap = self._ap_repo.get_by_uid(ap_uid)
    if ap is None:
        raise NotFound(GrcErrorCode.GRC_AP_NOT_FOUND)
    rnd = self._rounds.get_by_assessment_plan_id(ap.id)
    if rnd is None:
        raise NotFound(GrcErrorCode.GRC_ROUND_NOT_FOUND)
    participant = self._participant_domain_service.get_one(
        ProjectParticipantQueryEntity(project_id=rnd.project_id, user_id=curr_user_id))
    if participant is None:
        raise ForbiddenError(GrcErrorCode.GRC_NOT_AUDITOR)  # 或更貼切的既有 forbidden 碼
    return ap
```

> 實作時確認 `ProjectParticipantQueryEntity` 已 import；`resolve_ap_and_check_auditor` 已含 phase + auditor/manager，confirm/discard/parse 用它、preview 用 participant 版。

- [ ] **Step 3: 跑既有 AP 測試確認沒改壞**
Run: `pytest test/ -k "assessment_plan or ap_" -q`（或既有相關測試檔）
Expected: 綠（改名無行為變更）。

- [ ] **Step 4: Commit**（C1+C2 一起）
```bash
git add common/code/grc_error_code.py app/grc/service/assessment_plan_app_service.py
git commit -m "feat(FR-043): AP docx error codes + gate helper 提為 public（auditor/participant 兩版）"
```

### Task C3: serializer

**Files:**
- Create: `api/project/serializers/ap_docx_import.py`

- [ ] **Step 1: 寫 schema**（confirm 的 tasks 復用 `_TaskItem`；parties 帶配對決策）

```python
from marshmallow import Schema, fields, EXCLUDE
from api.project.serializers.audit_round import _TaskItem  # 復用行程形狀


class ApDocxImportParseResponseSchema(Schema):
    parse_uid = fields.Str()
    status = fields.Str()
    error_code = fields.Str(allow_none=True)
    error_message = fields.Str(allow_none=True)


class ApDocxImportPreviewResponseSchema(Schema):
    """GET /ap-docx-import/<parse_uid>：解析摘要 + 建議 payload + party 配對建議。"""
    parse_uid = fields.Str()
    status = fields.Str()
    adapter_version = fields.Str(allow_none=True)
    ttl_expires_at = fields.Str(allow_none=True)
    error_code = fields.Str(allow_none=True)
    error_message = fields.Str(allow_none=True)
    # 建議行程（單筆，_TaskItem 形狀）+ 章節摘要 + 參考資訊 + party 配對建議
    suggested_task = fields.Dict(allow_none=True)      # _TaskItem shape（FE 直接填表）
    sections = fields.Dict(allow_none=True)            # {header: text} 供 FE 顯示 chips
    reference_info = fields.Dict(allow_none=True)      # {basis_text, objective_text}
    party_suggestion = fields.Dict(allow_none=True)    # {name, matched_party_uid|None, candidates:[...]}


class _ConfirmPartySchema(Schema):
    class Meta:
        unknown = EXCLUDE
    name = fields.Str(required=True)
    matched_party_uid = fields.Str(allow_none=True)    # 非空 = 配對既有；None = 建新


class ApDocxImportConfirmRequestSchema(Schema):
    class Meta:
        unknown = EXCLUDE
    # user 預覽修改後的行程（單筆或多筆，append 到既有）
    tasks = fields.List(fields.Nested(_TaskItem), required=True)
    parties = fields.List(fields.Nested(_ConfirmPartySchema), load_default=list)


class ApDocxImportConfirmResponseSchema(Schema):
    parse_uid = fields.Str()
    status = fields.Str()
    import_summary = fields.Dict()
```

### Task C4: app service（核心）

**Files:**
- Create: `app/grc/service/ap_docx_import_app_service.py`
- Test: `test/test_ap_docx_import_app_service.py`

- [ ] **Step 1: 寫 failing 測試骨架**（logger patch fixture 必備 — DBLogHandler 對 SessionLocal=None 會炸）

```python
import pytest
from unittest.mock import MagicMock, patch


@pytest.fixture(autouse=True)
def _patch_logger():
    with patch("app.grc.service.ap_docx_import_app_service.logger", MagicMock()):
        yield

# 測試涵蓋（詳細期望由 feature-test-planner Phase E 展開，這裡先放關鍵行為）：
# - upload_and_parse: 非 .docx → GRC_DOCX_INVALID_FILE；>20MB → GRC_AP_DOCX_FILE_TOO_LARGE；
#   全 adapter miss → GRC_AP_DOCX_FORMAT_UNSUPPORTED；成功 → status awaiting_review + parse_uid
# - get_parse_result: TTL 過期 → status expired；非 participant → 403
# - confirm_import: 非 awaiting_review → 412；TTL 過期 → 412；
#   append 不覆蓋既有行程；party 同名走配對不呼叫 add_ap_party
```

- [ ] **Step 2: 實作 app service**（結構 mirror `SspDocxImportAppService`，簡化為 AP）

要點（實作紀律，逐條照做）：
- **常數**：`_DOCX_MAX_SIZE = 20 * 1024 * 1024`、`_PARSE_JOB_TTL_HOURS = 24`（AP 檔案本地定義，不 import oscal 版，避免跨模組耦合）。
- **`__init__` 注入**：`parse_job_domain_service`、`parser`（= `ApReportParserRegistry`）、`file_upload_service`、`assessment_plan_app_service`（復用 gate + set_tasks + add_ap_party + list_ap_parties + `_ap_detail` 需要的讀取；`_ap_detail` 是私有 → 改用 public 讀取：見下方 append 轉換）。
- **`upload_and_parse(file, ap_uid, user_context)` `@transaction`**：
  1. gate：`self._ap_svc.resolve_ap_and_check_auditor(ap_uid, user_context.id)`（parse 需 manager/auditor + audit_planning 階段）
  2. 驗 `.docx`（`GRC_DOCX_INVALID_FILE`）、size（`GRC_AP_DOCX_FILE_TOO_LARGE`）
  3. `job = self._parse_job.create(source_uid=ap_uid, file_name=…, file_size=…, tenant_id=user_context.tenant_id, user=user_context.login_name)`
  4. 存暫存檔（`tempfile`，mirror ssp 版；上傳 file_upload_service 取 file_uid 存 `file_path`）
  5. `try: parsed = self._parser.parse_file(tmp_path)`；`except ApReportUnsupportedFormat as e:` → `write_error` + 回 `{parse_uid, status:"failed", error_code: GRC_AP_DOCX_FORMAT_UNSUPPORTED, error_message: 帶 e.supported_versions}`；`except Exception:` → `write_error(GRC_DOCX_PARSE_FAILED)`
  6. 成功：組 `parsed_result`（見 Step 3 shape）→ `write_parsed_result` → 清暫存檔 → 回 `{parse_uid, status:"awaiting_review"}`
- **`get_parse_result(parse_uid, user_context)` `@transaction`**：
  1. `job = self._parse_job.get_one`；不存在/非 active → 404；tenant 不符且非 admin → 404
  2. `self._ap_svc.resolve_ap_and_check_participant(job.source_uid, user_context.id)`（preview 任一 participant）
  3. TTL 過期 → `update_status(expired)` + job.status="expired"
  4. 回攤平 response（`parse_uid`/`status`/`adapter_version`/`ttl_expires_at`/`suggested_task`/`sections`/`reference_info`/`party_suggestion`），party_suggestion 的配對建議在**此讀路徑**算（比對既有 `list_ap_parties(ap_uid)` 同名）
- **`discard_parse(parse_uid, user_context)` `@transaction`**：404 檢查 + `resolve_ap_and_check_participant`（**不可用 auditor 版** — 它含 `assert_round_phase({"audit_planning"})`，輪次推進後廢棄殘留 parse job 會誤 412；廢棄是清理，不該卡階段。比照 SSP discard 只 tenant/participant gate）+ `deactivate`
- **`confirm_import(parse_uid, payload, user_context)` `@transaction`**：
  1. `job` 404 檢查；`job.status != "awaiting_review"` → 412 `GRC_DOCX_PARSE_JOB_NOT_AWAITING`；TTL 過期 → 412 `GRC_DOCX_PARSE_JOB_EXPIRED`
  2. gate：`ap = self._ap_svc.resolve_ap_and_check_auditor(job.source_uid, user_context.id)`（雙重保險，set_tasks 內也會再擋）
  3. **party 建立/配對**：先讀 `self._ap_svc.list_ap_parties(job.source_uid)` 建 `name(casefold)→uid` map。對 `payload["parties"]`：`matched_party_uid` 非空 → 用該 uid；否則 name 已在 map → 取既有 uid（**不 call add_ap_party**，避免同名 `ConflictError` 整筆 rollback，design §4.1）；否則 `res = self._ap_svc.add_ap_party(job.source_uid, {"party_type":"organization","name":name,"role":"assessor"}, curr_user, curr_user_id)` → **取 `res["uid"]`**（`add_ap_party` 回傳 key 是 `uid`，**不是** `party_uuid`）。收集所有 resolved uid 成 `resolved_party_uids`。
  4. **把 party 接進行程 participants**（reviewer #3：否則 org 建了但行程 participant 為空、稽核人員欄不顯示）：對 `payload["tasks"]` 每筆，把 `resolved_party_uids` 併進其 `participants`（`{"party_uuid": uid, "role_id": "assessor"}`，與既有去重）。
  5. **append 轉換**：讀既有行程 → 轉 `_TaskItem` → 串新行程
     ```python
     existing = self._ap_svc.get_ap_tasks_as_task_items(job.source_uid)  # 見下方 helper
     merged = existing + enriched_tasks  # enriched_tasks = 已接 participants 的 payload tasks
     self._ap_svc.set_tasks(job.source_uid, tasks=merged,
                            curr_user=user_context.login_name, curr_user_id=user_context.id)
     ```
  6. `write_import_summary(parse_uid, {tasks_appended, parties_created}, user)` → 回 `{parse_uid, status:"completed", import_summary}`

- [ ] **Step 3: `parsed_result` shape**（存進 JSONB；`suggested_task` 為 `_TaskItem`，方法/日期落位照 design §3）

```python
suggested_task = {
    "title": parsed.title,
    "type": "action",
    "timing": parsed.audit_date,   # date 字串；set_tasks 原樣存 ap_tasks.timing
    "methods": parsed.methods,
    "steps": [{"method": m, "description": g} for m, g in parsed.method_guidances.items()],
    "controls": [],                # user 預覽時補勾
    "subjects": [],                # user 預覽時補勾
    "participants": [],            # confirm 時依 party 配對結果補（見下）
    "description": _compose_description(parsed),  # 範圍全文 + 時段 +（勾選時）依據/目的
}
```
`_compose_description`：範圍敘述 + 稽核時段（`audit_date_text`）；依據/目的預設附上（FE checkbox 可取消 → confirm 送修改後的 `description`）。

- [ ] **Step 4: `AssessmentPlanAppService` 加 public append helper**（避免 ApDocxImportAppService 碰私有 `_ap_detail`）

Modify `app/grc/service/assessment_plan_app_service.py`：
```python
def get_ap_tasks_as_task_items(self, ap_uid: str) -> list:
    """讀既有行程並轉成 set_tasks 可吃的 _TaskItem shape（append 用）。
    caller 必須在 @transaction scope 內。"""
    ap = self._ap_repo.get_by_uid(ap_uid)
    if ap is None:
        raise NotFound(GrcErrorCode.GRC_AP_NOT_FOUND)
    detail = self._ap_detail(ap)
    items = []
    for t in detail["tasks"]:
        items.append({
            "title": t["title"], "type": t.get("type"), "timing": t.get("timing"),
            "methods": t.get("methods") or [],
            "controls": t.get("controls") or [],
            "subjects": t.get("subjects") or [],
            "participants": t.get("participants") or [],
            "steps": t.get("steps") or [],
            "description": t.get("description"),
        })
    return items
```

- [ ] **Step 5: 跑測試確認 pass**
Run: `pytest test/test_ap_docx_import_app_service.py -v`
Expected: PASS。

- [ ] **Step 6: Commit**
```bash
git add app/grc/service/ap_docx_import_app_service.py \
        app/grc/service/assessment_plan_app_service.py \
        api/project/serializers/ap_docx_import.py \
        test/test_ap_docx_import_app_service.py
git commit -m "feat(FR-043): ApDocxImportAppService（parse/preview/confirm，append + party 配對）"
```

### Task C5: route + 註冊 + DI app service wire

**Files:**
- Create: `api/project/routes/ap_docx_import_route.py`
- Modify: `api/project/__init__.py`
- Modify: `di_containers/grc/grc_containers.py`

- [ ] **Step 1: route**（4 個 MethodResource，multipart upload；mirror `ssp_scoped_docx_import_route.py`）
  - `POST /ap/<ap_uid>/docx-imports/parse` → `upload_and_parse`
  - `GET /ap-docx-import/<parse_uid>` → `get_parse_result`
  - `DELETE /ap-docx-import/<parse_uid>` → `discard_parse`
  - `POST /ap-docx-import/<parse_uid>/confirm` → `confirm_import`（load `ApDocxImportConfirmRequestSchema`）
  - DI：`Provide[Containers.grc_container.ap_docx_import_app_service]`（確認 grc_container 在 `Containers` 的 attr 名）
- [ ] **Step 2: 註冊** `api/project/__init__.py`（緊接 `ApPartiesRoute` 之後）：
  ```python
  api.add_resource(ApDocxImportUploadRoute, '/ap/<string:ap_uid>/docx-imports/parse')
  api.add_resource(ApDocxImportRoute, '/ap-docx-import/<string:parse_uid>')
  api.add_resource(ApDocxImportConfirmRoute, '/ap-docx-import/<string:parse_uid>/confirm')
  ```
- [ ] **Step 3: wire app service** in grc_containers（app service Factory；注入 parse-job domain、`ap_report_parser_registry` Singleton（含亞航 adapter）、file_upload_service、assessment_plan_app_service）
  ```python
  ap_report_parser_registry = providers.Singleton(
      ApReportParserRegistry,
      adapters=providers.List(providers.Singleton(AirAsiaCmmcL1V1Adapter)),
  )
  ap_docx_import_app_service = providers.Factory(
      ApDocxImportAppService,
      parse_job_domain_service=ap_docx_parse_job_domain_service,
      parser=ap_report_parser_registry,
      file_upload_service=upload_file_container.file_upload_service,
      assessment_plan_app_service=assessment_plan_app_service,
  )
  ```
  （`upload_file_container` 已於 A7 Step 3 補進 GrcContainer + root containers.py。）
- [ ] **Step 4: wiring 模組**：確認 `api.project.routes.ap_docx_import_route` 被 DI `wire()` 掃到（看 `config/` auto-scan 是否含 `api/project`；ssp route 已被掃 → 同 package 應自動）。
- [ ] **Step 5: BE 起得來冒煙**
Run: `set -a; source .env; set +a; python -c "import config.app_modules"`（或最小 app factory import）
Expected: 無 DI resolution error。
> **提醒 user 重啟 BE**（無 hot reload）後才可手測。
- [ ] **Step 6: Commit**
```bash
git add api/project/routes/ap_docx_import_route.py api/project/__init__.py di_containers/grc/grc_containers.py
git commit -m "feat(FR-043): AP docx import route + DI wiring"
```

---

## Phase D — FE（ap-authoring dialog wizard）

> 動工前必讀：FE `CLAUDE.md` + BE `docs/claude/frontend-overview.md`。先 Read `src/components/grc/ssp-docx-import-v2/SspDocxImportPage.vue`（結構參考）、`src/composables/useSspDocxDraft.js`、`src/views/project/RoundApAuthoringView.vue`（入口 + 現有行程卡欄位）。

### Task D1: api 常數 + service

**Files:**
- Modify: `src/config/api/api.js`
- Create: `src/service/ApDocxImportService.js`

- [ ] api.js 加 `AP_DOCX_IMPORT_PARSE`（`/ap/:apUid/docx-imports/parse`）、`AP_DOCX_IMPORT`（`/ap-docx-import` + `/:uid` | `/:uid/confirm` | DELETE）
- [ ] service：parse（multipart FormData）/ getPreview / confirm / discard（繼承 `BaseService`，注意 BaseService 返回值規則見 frontend-overview.md）

### Task D2: draft composable

**Files:**
- Create: `src/composables/useApDocxDraft.js`

- [ ] mirror `useSspDocxDraft`：key by `parse_uid`，存 user 在 Step 2 的編輯（行程欄位 + 勾選 + party 決策 + 依據/目的 checkbox），防 TTL 過期丟編輯。

### Task D3: dialog wizard

**Files:**
- Create: `src/components/grc/ap-docx-import/ApDocxImportDialog.vue`

- [ ] **Step 1**（上傳）：`FileUpload`（.docx、20MB）→ parse → `ProgressSpinner`（先開 dialog 再載入慣例）；失敗（格式不符）顯示錯誤 + 支援版本清單。
- [ ] **Step 2**（預覽修改）：
  - 上：解析摘要（`adapter_version` + 章節 chips）+ 提示「將新增 N 筆行程到現有 M 筆之後」
  - 中：行程預填表單（標題/日期/方法 MultiSelect/每方法查核指引/備註，全可改；控制項 + 受評對象兩欄就地補勾，選項母體同 authoring 頁）
  - 下：稽核人員配對（`party_suggestion` → 下拉選既有 party 或「建立新的」）+ 參考資訊區（依據/目的全文，checkbox「附進備註」預設勾選）
  - `Steps` 用 `:active-step`（PrimeVue 3.53 quirk）；MultiSelect / SelectButton quirk 注意；draft 存 localStorage
- [ ] **Step 3**（完成）：confirm → toast + 匯入摘要 → 關 dialog + refresh tasks/parties/兩推導 tab
- [ ] dialog 關閉前有未確認編輯 → 二次確認（sheet-dismiss-confirm）

### Task D4: 入口按鈕

**Files:**
- Modify: `src/views/project/RoundApAuthoringView.vue`

- [ ] header「匯入稽核計畫」按鈕：僅 `audit_planning` 階段 + participant role ∈ {manager, auditor} + **AP 已存在** 時顯示 → 開 `ApDocxImportDialog`。
- [ ] i18n（zh_Hant_TW / en）文案 + 新 error code 文案（`GRC_400090` / `GRC_400091`）。
- [ ] **Commit**（FE 各檔顯式 add；push 等 user）。

---

## Phase E — 測試計畫 + e2e

### Task E1: test-plan.md

- [ ] 用 `feature-test-planner` agent 讀 design.md + 本 plan → 產 `docs/features/FR-043-2607-ap-docx-import/test-plan.md`（BE pytest 單元/整合 + FE E2E BDD + traceability matrix，對照 design §7 測試重點）。

### Task E2: e2e（在 `compliance-manager-test` repo）

- [ ] 依 test-plan.md 在測試專案寫 feature/step/page：上傳亞航樣本 → 預覽改欄位 → 確認 → 行程出現在 ap-authoring 列表 + 稽核人員出現在 parties。**不在 BE 加 e2e。**

---

## 完成的定義（收尾前 self-check，然後停下等 user）

- [ ] BE `pytest test/test_ap_report_parser_airasia.py test/test_ap_docx_import_app_service.py -q` 綠
- [ ] 既有 AP 測試無回歸（gate 改名）
- [ ] 手動 smoke（user 重啟 BE 後）：上傳亞航樣本 → preview 解析出 design §5 驗收欄位 → confirm → ap-authoring 頁看到 append 行程 + 受評控制項/受評對象 tab 推導回填 + 稽核人員在 parties
- [ ] 給 user 一句話 status + 手測 checklist
- [ ] **收尾動作（changelog / SUMMARY / Notion / FIXED 標記）等 user 下令才做**

## 驗收基準（design §5，逐項對照）

上傳亞航樣本後 preview 應解析出：標題、稽核日期 `2026-06-02`、稽核單位「鈊安資安顧問」、方法 `INTERVIEW`/`EXAMINE`/`TEST` + 每方法查核指引、範圍敘述進備註、依據+目的作為參考資訊（checkbox 預設附進備註）。confirm 後行程 append、受評控制項/受評對象 tab 推導回填、稽核人員出現在 parties。
