# Phase A1 Implementation Plan — Excel 樣板設計 + 下載

> **Phase**：A1
> **級別**：中型
> **依賴**：A0.1 已 ship
> **預計工時**：BE 2.5d + FE 0.5d + test 0.5d ≈ 3-4 working day
> **對應 design**：`docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A1.md`

---

## Task 切分概覽

| Task | 主題 | repo | 預估 | 依賴 |
|------|------|------|------|------|
| 0 | Pre-flight verification（6 個假設）| BE | 0.25d | — |
| 1 | Skeleton — route + app service + DI 空殼 | BE | 0.25d | T0 |
| 2 | Generator base — openpyxl helper 共用層 | BE | 0.5d | T1 |
| 3 | Blank mode 完整實作（9 sheet headers + 下拉 + lookup）| BE | 0.5d | T2 |
| 4 | Filled mode 資料組裝（非 07 控制項 sheet）| BE | 0.5d | T3 |
| 5 | 07_控制項與AO sheet profile-scoped 邏輯 + filled | BE | 0.5d | T3, T4 |
| 6 | FE 下載按鈕 + axios blob + filename | FE | 0.5d | T1 |
| 7 | E2E + smoke + changelog | test + BE | 0.5d | T5, T6 |

> Task 1-5 必須序列，Task 6 (FE) 可在 Task 1 完成 endpoint 介面後與 BE 並行進行。

---

## Task 0 — Pre-flight Verification

> **這個 phase 把 verify 拉到最前面，動 code 前確保假設正確**（A0.1 學到的教訓 — design 寫好到開工經常 days/weeks，期間 method 改名 / entity shape 變）

### 0.1 verify MF ↔ system_characteristic 鉤稽路徑

```bash
# 確認 MF 怎麼接 system_characteristic
grep -rn "module_frame.*system_characteristic\|system_characteristic.*module_frame" \
  app/ infra/ domain/ 2>/dev/null

# 看 SSP versioning service 怎麼從 MF init system_characteristic
grep -n "system_characteristic" app/oscal/service/ssp_versioning_service.py
```

**期待結果**：找到 (a) MF 有獨立 metadata 欄位（不走 SSP system_characteristic）或 (b) MF 透過 init SSP 順帶 init system_characteristic。

**Impact**：影響 design.md §4.2 `01_基本資料` sheet 來源 — 改成 MF 自己的 metadata 欄位或保留 system_characteristic 結構。

### 0.2 verify profile → controls 取法

```bash
# 看 jedi-oscal profile service 有沒有 "list controls of profile" method
find ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal -name "profile*.py" | head -10
grep -rn "def.*profile.*control\|def.*get.*controls\|def.*list.*controls" \
  ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/ 2>/dev/null | head -20

# 看 GRC project start flow 怎麼從 profile clone control 到 AP
grep -n "profile.*control\|init_ap_controls" app/project/service/oscal_project_service.py
```

**期待結果**：找到現成 `ProfileService.get_profile_controls(profile_uid)` 或要組合 `profile_controls` table + `catalog_controls` 兩層查詢。

**Impact**：Task 5 控制項 sheet 的核心邏輯。

### 0.3 verify tenant user list 取法

```bash
grep -rn "def.*list.*tenant\|tenant.*users\|users.*tenant" \
  ~/Projects/Jedicogy/module/jedi-python-package/jedi-auth/jedi_auth/domain/ 2>/dev/null | head -10
```

**期待結果**：jedi-auth `UserDomainService` 有 list method（RLS 自動 filter）。

### 0.4 verify reference docs pool 範圍

```bash
# MF own (existing) vs tenant pool
ls infra/module_frame/models/module_frame_reference_document.py
grep -rn "reference_document" infra/compliance/ 2>/dev/null | head -10
```

**期待結果**：確認 `module_frame_reference_document` 是 MF own（每個 MF 自己一份），tenant 沒有 cross-MF reference doc pool。樣板 `08_程序書` sheet 預填從 MF own 來。

**Impact**：lookup sheet `_lookup_ref_docs` 範圍 — 改成 MF own 而非 tenant scope。

### 0.5 verify oscal_responsible_parties context_type='module_frame' 支援

```bash
grep -rn "context_type.*module_frame\|'module_frame'.*context\|MODULE_FRAME" \
  app/ infra/ domain/ ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/ 2>/dev/null \
  | grep -v __pycache__ | head -15
```

**期待結果**：context_type 是 varchar，application 層自由用；確認既有有 `'module_frame'` 寫入路徑（既有 module-frame-template-defaults feature 應該已有用）。

### 0.6 verify openpyxl DefinedName API

```bash
poetry run python3 -c "
from openpyxl import Workbook
from openpyxl.workbook.defined_name import DefinedName
wb = Workbook()
dn = DefinedName(name='test_range', attr_text='Sheet1!\$A\$1:\$A\$10')
wb.defined_names['test_range'] = dn
print('API works:', wb.defined_names['test_range'].attr_text)
"
```

**期待結果**：能寫入 + 讀出。若 API 不一致用 `wb.defined_names.append(dn)` 或 `wb.create_named_range(name, ws, value)`。

### 0.7 verify schema fix（design §10 列）

如果 0.1-0.5 有任何項目跟 design.md 假設不符，在 Task 0 結束時 **修 design.md** 後再進 Task 1。**不照舊 design 硬幹**。

### Acceptance

- [ ] 6 個 verification 結果寫入 implementation log 或 design.md §10 變更段
- [ ] design.md 若有不符已 update

---

## Task 1 — Skeleton（route + app service + DI 空殼）

### 1.1 檔案改動

| 檔案 | 動作 |
|------|------|
| `api/module_frame/excel_template_router.py` | 新建 — Flask Blueprint + Resource，GET endpoint，呼叫 app service，回 file stream |
| `api/module_frame/__init__.py` 或 `create_module()` | 加新 route 到 module |
| `app/module_frame/excel_template_app_service.py` | 新建 — `@transaction` skeleton，`generate(mf_uid, mode, locale, curr_user) -> BytesIO + filename` 簽章，內含 raise NotImplementedError |
| `app/module_frame/excel_template_generator.py` | 新建 — `generate(data_bundle) -> BytesIO` 純函式 skeleton |
| `di_containers/module_frame/module_frame_containers.py` | 加 `excel_template_app_service` provider |
| `common/code/grc_error_code.py` | 確認 `GRC_MODULE_FRAME_NOT_FOUND` 已存在；若無新增 |

### 1.2 route 範例

```python
# api/module_frame/excel_template_router.py
from flask import Blueprint, send_file, request
from flask_restful import Api, Resource
from dependency_injector.wiring import inject, Provide
from common.middleware.jwt_mw import jwt_required
from di_containers.containers import Container

## ⚠️ T0 Verify 後的命名更新
## 既有 api/module_frame/__init__.py 已有 blueprint (url_prefix='/api/1.0')，
## 不另開 Blueprint — 把 route 加進既有 create_module() 即可。
api = Api(bp)


class ExcelTemplateResource(Resource):
    method_decorators = [jwt_required()]

    @inject
    def get(
        self,
        module_frame_uid: str,
        excel_template_app_service=Provide[Container.module_frame_container.excel_template_app_service],
    ):
        mode = request.args.get("mode", "blank")
        if mode not in ("blank", "filled"):
            raise BadRequestError(GrcErrorCode.GRC_INVALID_PARAM)
        locale = request.args.get("locale")
        bytesio, filename = excel_template_app_service.generate(
            module_frame_uid=module_frame_uid,
            mode=mode,
            locale=locale,
        )
        return send_file(
            bytesio,
            mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            as_attachment=True,
            download_name=filename,
        )


api.add_resource(SspImportTemplateResource, "/module-frame/<string:module_frame_uid>/ssp-import-template")
```

### 1.3 Acceptance

- [ ] 起 BE → curl GET endpoint → 回 501 NotImplementedError（route 通、DI 通）
- [ ] mode 非 blank/filled 回 400
- [ ] 不存在 MF uid 回 404
- [ ] 無 JWT 回 401

---

## Task 2 — Generator Base（openpyxl helper 共用層）

### 2.1 新建檔案

```
app/module_frame/excel_template/
├── __init__.py
├── generator.py             # ExcelTemplateGenerator class (主 entry)
├── sheet_definitions.py     # 8 個 sheet 的 column spec (dataclass)
├── styles.py                # PatternFill / Font / Alignment 常數
├── lookup_builder.py        # 隱藏 lookup sheet + DefinedName 寫入 helper
└── data_validation_builder.py  # DataValidation list helper（enum / named range）
```

### 2.2 核心抽象

```python
# sheet_definitions.py
from dataclasses import dataclass
from enum import StrEnum

class LookupSource(StrEnum):
    USERS = "users"
    ORG_UNITS = "org_units"
    DEVICES = "devices"
    INFO_SYSTEMS = "info_systems"
    REF_DOCS = "ref_docs"
    ORGS = "orgs"

@dataclass(frozen=True)
class ColumnDef:
    key: str                          # 對應 data row dict key
    header_i18n_key: str              # babel key
    required: bool = False
    enum_values: tuple[str, ...] | None = None   # 直接列舉（< 255 char）
    lookup_source: LookupSource | None = None    # 用 named range
    width: int = 20                   # column width

@dataclass(frozen=True)
class SheetDef:
    sheet_name: str
    description_i18n_key: str
    columns: tuple[ColumnDef, ...]

ALL_SHEETS = (
    SheetDef(
        sheet_name="01_基本資料",
        description_i18n_key="excel_template.sheet.metadata",
        columns=(
            ColumnDef("system_name", "field.system_name", required=True),
            ColumnDef("sensitivity_level", "field.sensitivity_level", required=True,
                      enum_values=("low", "moderate", "high")),
            ...
        ),
    ),
    ...  # 其他 7 個資料 sheet
)
```

### 2.3 lookup_builder.py

```python
def build_lookup_sheet(wb, source: LookupSource, values: list[str]) -> str:
    """建隱藏 lookup sheet + DefinedName，回傳 defined name 可被 DataValidation 引用"""
    sheet_name = f"_lookup_{source.value}"
    ws = wb.create_sheet(sheet_name)
    ws.sheet_state = "hidden"
    ws.cell(row=1, column=1, value=source.value)  # header
    for i, v in enumerate(values, start=2):
        ws.cell(row=i, column=1, value=v)
    
    defined_name = f"_lookup_{source.value}_range"
    end_row = len(values) + 1
    dn = DefinedName(name=defined_name, attr_text=f"'{sheet_name}'!$A$2:$A${end_row}")
    wb.defined_names[defined_name] = dn  # ⚠️ 依 Task 0.6 verify 結果調整 API
    return defined_name
```

### 2.4 Acceptance + unit test

新建 `tests/test_excel_template_generator_base.py`：

- [ ] `build_lookup_sheet` 寫 hidden sheet + DefinedName 正確
- [ ] `ColumnDef` enum 與 lookup 互斥（同時設兩者拋 assert）
- [ ] 必填 header 黃底 styling 套用正確
- [ ] DataValidation enum 直接列舉 < 255 字元限制檢查
- [ ] 不需要 DB，純函式測

預計 8-10 個 unit test。

---

## Task 3 — Blank Mode 完整實作

### 3.1 generator.generate(data_bundle, mode='blank') 路徑

```python
class ExcelTemplateGenerator:
    def generate(self, data_bundle: TemplateDataBundle) -> BytesIO:
        wb = Workbook()
        wb.remove(wb.active)  # 移除預設 Sheet
        
        # 1. 建 lookup sheets（先建，sheet 內 DataValidation 才能引用）
        lookup_refs = {}
        for source in LookupSource:
            values = data_bundle.lookups.get(source, [])
            if values:
                lookup_refs[source] = build_lookup_sheet(wb, source, values)
        
        # 2. 00_說明 sheet
        self._build_intro_sheet(wb, data_bundle)
        
        # 3. 8 個資料 sheet
        for sheet_def in ALL_SHEETS:
            self._build_data_sheet(wb, sheet_def, data_bundle, lookup_refs)
        
        # 4. 輸出
        bio = BytesIO()
        wb.save(bio)
        bio.seek(0)
        return bio
```

### 3.2 blank mode 的 data_bundle

```python
@dataclass
class TemplateDataBundle:
    mf: ModuleFrameEntity
    profile_info: dict  # framework name / version / profile uid
    locale: str
    mode: str  # 'blank' or 'filled'
    
    # filled mode 才有
    metadata: dict | None = None
    parties_org: list[dict] = field(default_factory=list)
    parties_person: list[dict] = field(default_factory=list)
    devices: list[dict] = field(default_factory=list)
    info_systems: list[dict] = field(default_factory=list)
    leveraged: list[dict] = field(default_factory=list)
    controls_with_aos: list[dict] = field(default_factory=list)
    ref_docs: list[dict] = field(default_factory=list)
    
    # 兩 mode 都有（下拉資料源）
    lookups: dict[LookupSource, list[str]] = field(default_factory=dict)
```

### 3.3 app_service.generate blank 路徑

```python
@transaction
def generate(self, module_frame_uid, mode='blank', locale=None):
    locale = locale or get_user_context().locale
    mf = self._mf_domain_service.get_one(ModuleFrameQueryEntity(uid=module_frame_uid))
    if not mf:
        raise NotFound(GrcErrorCode.GRC_MODULE_FRAME_NOT_FOUND)
    
    bundle = TemplateDataBundle(
        mf=mf,
        profile_info=self._fetch_profile_info(mf.oscal_profile_uid),
        locale=locale,
        mode=mode,
        lookups=self._fetch_lookups(mf),  # 兩 mode 都要
    )
    if mode == 'filled':
        # Task 4 / 5 補
        self._populate_filled_data(bundle, mf)
    
    bio = self._generator.generate(bundle)
    filename = f"ssp_template_{mf.name}_{mode}_{date.today().strftime('%Y%m%d')}.xlsx"
    return bio, filename
```

### 3.4 Acceptance + unit test

新建 `tests/test_excel_template_blank_mode.py`：

- [ ] blank mode 產出 xlsx 含 9 + 6 = 15 sheet（含 hidden lookups）
- [ ] 每資料 sheet header row 必填欄黃底
- [ ] enum 欄位有 DataValidation
- [ ] lookup 欄位指向正確的 named range
- [ ] tenant 隔離（mock user context tenant_a → 撈 tenant_a users）
- [ ] curl/smoke：起 BE → GET endpoint → 下載 xlsx → openpyxl re-load 驗 sheet count

預計 8-10 個 unit test + 1 個 integration test。

---

## Task 4 — Filled Mode 資料組裝（非 07 控制項 sheet）

### 4.1 app_service._populate_filled_data

```python
def _populate_filled_data(self, bundle: TemplateDataBundle, mf: ModuleFrameEntity):
    # 01_基本資料
    bundle.metadata = self._fetch_mf_metadata(mf)  # ⚠️ 依 Task 0.1 verify 結果調整來源
    
    # 02_單位 / 03_參與人員
    parties = self._party_domain_service.list_by_context(
        context_type='module_frame', context_id=mf.id
    )
    bundle.parties_org = [p for p in parties if p.party_type == 'organization']
    bundle.parties_person = [p for p in parties if p.party_type == 'person']
    
    # 04_設備 / 05_資訊系統 / 06_外部利用服務（A0.1 ssp_system_implementation_items, scope=module_frame）
    main = self._system_implementation_main_domain_service.find_by_scope(
        scope_type='module_frame', scope_id=mf.id
    )
    if main:
        items = self._system_implementation_item_domain_service.list_by_main(main.id)
        bundle.devices = [i for i in items if i.implementation_type == 'hardware']
        bundle.info_systems = [i for i in items 
                                if i.implementation_type in ('component','system','subsystem','service','software')]
        bundle.leveraged = [i for i in items if i.implementation_type == 'leveraged-authorization']
    
    # 08_程序書（依 Task 0.4 verify — 預期是 MF own）
    bundle.ref_docs = self._ref_doc_domain_service.list_by_mf(mf.id)
    
    # 07_控制項與AO 留給 Task 5
```

### 4.2 generator 內每 sheet builder 加 filled row 寫入

```python
def _build_data_sheet(self, wb, sheet_def, bundle, lookup_refs):
    ws = wb.create_sheet(sheet_def.sheet_name)
    
    # Header (Task 2 已建)
    self._write_header(ws, sheet_def, bundle.locale)
    
    # Filled mode: 寫資料 row
    if bundle.mode == 'filled':
        data_rows = self._get_data_for_sheet(sheet_def, bundle)
        for row_idx, row_data in enumerate(data_rows, start=2):
            for col_idx, col_def in enumerate(sheet_def.columns, start=1):
                ws.cell(row=row_idx, column=col_idx, value=row_data.get(col_def.key))
    
    # 預留空白 row 供使用者新增（blank mode 全空，filled mode 在資料 row 之後加 5-10 row）
    self._apply_data_validations(ws, sheet_def, lookup_refs, max_row=...)
```

### 4.3 Acceptance + unit test

新建 `tests/test_excel_template_filled_mode.py`：

- [ ] filled mode 對 MF 有 5 個 parties → sheet 02 + 03 加總 5 row（非空 row）
- [ ] devices / info_systems / leveraged 三 sheet 對齊 `ssp_system_implementation_items` scope=module_frame 的資料
- [ ] 既有 reference docs 倒進 08 sheet
- [ ] DataValidation 仍套用到 row（含 filled row + 後續空 row）
- [ ] MF 沒任何資料時 filled 跟 blank 結果一致（header only）

預計 6-8 個 unit test。

---

## Task 5 — 07_控制項與AO Sheet（profile-scoped + filled）

### 5.1 核心邏輯

```python
def _populate_controls_with_aos(self, bundle, mf):
    # 1. 取 profile 範圍內全部 control（依 Task 0.2 verify 結果）
    profile_controls = self._profile_domain_service.list_controls_by_profile(
        profile_uid=mf.oscal_profile_uid
    )
    # profile_controls: list of ProfileControlEntity, 每個有 catalog_control_id
    
    # 2. 取每 control 的 AO list
    control_ids = [pc.catalog_control_id for pc in profile_controls]
    aos = self._ao_domain_service.list_by_control_ids(control_ids)
    aos_by_control = defaultdict(list)
    for ao in aos:
        aos_by_control[ao.catalog_control_id].append(ao)
    
    # 3. 取 MF 既有的 implementation_statement / ao_statement
    control_defaults = {
        cd.catalog_control_id: cd
        for cd in self._control_default_domain_service.list_by_mf(mf.id)
    }
    ao_defaults = {
        aod.catalog_control_objective_id: aod
        for aod in self._ao_default_domain_service.list_by_mf(mf.id)
    }
    
    # 4. 組 flat row list（父子用 statement_id / control_id 區分）
    rows = []
    for pc in profile_controls:
        # 父 row: control
        cd = control_defaults.get(pc.catalog_control_id)
        rows.append({
            "statement_id": None,  # control 父 row 留空
            "control_id": pc.control_number,  # AC-1
            "control_name": pc.control_title_i18n.get(bundle.locale, pc.control_title),
            "objective_id": None,
            "objective_name": None,
            "impl_status": cd.impl_status if cd else None,
            "statement": cd.implementation_statement if cd else None,
            "reference_doc": cd.reference_doc_name if cd else None,
        })
        # 子 row: AOs
        for ao in aos_by_control.get(pc.catalog_control_id, []):
            aod = ao_defaults.get(ao.id)
            rows.append({
                "statement_id": ao.uid,
                "control_id": pc.control_number,  # 子 row 也填 control_id 方便 A2 parser 識別父子
                "control_name": None,
                "objective_id": ao.objective_number,
                "objective_name": ao.objective_title_i18n.get(bundle.locale),
                "impl_status": aod.impl_status if aod else None,
                "statement": aod.statement if aod else None,
                "reference_doc": aod.reference_doc_name if aod else None,
            })
    
    bundle.controls_with_aos = rows
```

### 5.2 視覺標示

```python
# generator 內處理 07 sheet 時：
# control row（statement_id=None）淺灰底色，AO row 留白
CONTROL_ROW_FILL = PatternFill(start_color="F2F2F2", fill_type="solid")

for row_idx, row_data in enumerate(rows, start=2):
    is_control_row = row_data.get("statement_id") is None
    for col_idx, col_def in enumerate(sheet_def.columns, start=1):
        cell = ws.cell(row=row_idx, column=col_idx, value=row_data.get(col_def.key))
        if is_control_row:
            cell.fill = CONTROL_ROW_FILL
```

### 5.3 Acceptance + unit test

新建 `tests/test_excel_template_controls_sheet.py`：

- [ ] profile 內 10 個 control × 平均 3 個 AO → 40 row (10 control parents + 30 AO children)
- [ ] 父 row control_id 填、statement_id 空；子 row 兩者都填
- [ ] 已填的 control / AO 的 statement / impl_status 正確預填
- [ ] 未填的 control / AO row 保留 control_id 但 statement 空白
- [ ] 父 row 套灰底色 fill
- [ ] blank mode 此 sheet 仍列 profile 範圍 control（差別是 statement 全空）

預計 5-7 個 unit test。

---

## Task 6 — FE 下載按鈕

### 6.1 檔案改動

| 檔案 | 動作 |
|------|------|
| `compliance-manager-fe/src/views/.../ModuleFrameDetail.vue`（待確認路徑）| 工具列加 SplitButton：主按鈕「下載樣板」+ 下拉「空白 / 已填」 |
| `compliance-manager-fe/src/service/.../ModuleFrameService.js` | 新 method `downloadExcelTemplate(uid, mode)` |
| `compliance-manager-fe/src/config/api/api.js` | 加 endpoint constant `MODULE_FRAME_EXCEL_TEMPLATE` |
| `compliance-manager-fe/src/locales/zh-TW.json` / `en.json` | 加按鈕 / dropdown 文字 |

### 6.2 axios blob download

```js
// ModuleFrameService.js
async downloadExcelTemplate(uid, mode = 'blank') {
  const response = await this.axios.get(
    API.MODULE_FRAME_EXCEL_TEMPLATE.replace(':uid', uid),
    { params: { mode }, responseType: 'blob' }
  );
  // 從 Content-Disposition 取 filename
  const dispositionHeader = response.headers['content-disposition'] || '';
  const filenameMatch = dispositionHeader.match(/filename="?([^"]+)"?/);
  const filename = filenameMatch ? filenameMatch[1] : `template_${mode}.xlsx`;
  
  // 觸發 browser download
  const url = window.URL.createObjectURL(new Blob([response.data]));
  const link = document.createElement('a');
  link.href = url;
  link.setAttribute('download', filename);
  document.body.appendChild(link);
  link.click();
  link.remove();
  window.URL.revokeObjectURL(url);
}
```

### 6.3 Acceptance

- [ ] 點按鈕觸發下載，browser 收到 xlsx file
- [ ] filename 帶 mf name + mode + date
- [ ] blank / filled 切換正確帶 query param
- [ ] 沒 oscal_profile_uid 的 MF disable 按鈕（理論上不會，但防呆）
- [ ] i18n 切 en 後按鈕文字英文

---

## Task 7 — E2E + Smoke + Changelog

### 7.1 BE smoke

```bash
# 起 BE
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
lsof -ti:8000 | xargs kill -9 2>/dev/null
set -a; source .env; set +a
nohup poetry run python main_app.py > /dev/null 2>&1 &
sleep 3

# 取 dev token (略，使用既有 helper)
TOKEN="..."
MF_UID="..."  # 一個 dev DB 已存在的 MF uid

# Blank mode
curl -H "Authorization: Bearer $TOKEN" \
     -H "X-Tenant-ID: 102" \
     "http://localhost:8000/api/1.0/module-frame/$MF_UID/ssp-import-template?mode=blank" \
     -o /tmp/template_blank.xlsx
ls -la /tmp/template_blank.xlsx

# Filled mode
curl -H "Authorization: Bearer $TOKEN" \
     "http://localhost:8000/api/1.0/module-frame/$MF_UID/ssp-import-template?mode=filled" \
     -o /tmp/template_filled.xlsx

# 用 openpyxl 驗
poetry run python3 -c "
from openpyxl import load_workbook
for fn in ['/tmp/template_blank.xlsx', '/tmp/template_filled.xlsx']:
    wb = load_workbook(fn)
    print(f'{fn}: sheets={len(wb.sheetnames)}, visible={[s for s in wb.sheetnames if not s.startswith(\"_lookup_\")]}')"
```

### 7.2 E2E（compliance-manager-test）

新建 `features/.../mf_excel_template_download.feature`：

```gherkin
Feature: 合規資源庫匯出 Excel 樣板

  Background:
    Given 我以管理員身分登入
    And 我已開啟合規資源庫詳細頁

  Scenario: 下載空白樣板
    When 我點擊「下載樣板」並選擇「空白」
    Then 我應該收到一個 .xlsx 檔案
    And 該檔案應包含 9 個可見 sheet
    And 該檔案 sheet "07_控制項與AO" 應該包含 profile 範圍的 control row

  Scenario: 下載已填樣板
    Given 該合規資源庫已有 5 個 parties
    When 我點擊「下載樣板」並選擇「已填」
    Then 我應該收到一個 .xlsx 檔案
    And 該檔案 sheet "02_單位" 與 "03_參與人員" 加總應有 5 row 資料
```

對應 step + page object 寫入測試專案。

### 7.3 Changelog

```markdown
---
type: feat
breaking: false
modules: [module-frame, ssp-import-template]
commit: <main commit hash>
---

# feat: 合規資源庫 Excel 樣板下載（A1）

Phase 2 Track A 第一階段。提供 ModuleFrame 詳細頁下載 Excel 樣板（9 sheet 結構，blank / filled 兩模式）。filled 模式以 profile 範圍為基準預填 MF 既有資料（含 parties / devices / info_systems / leveraged / controls + AOs / reference docs）。

[詳細變更見 design-A1.md + implementation-plan-A1.md]
```

### 7.4 收尾

- [ ] 更新 `docs/features/FR-011.2-2605-ssp-import-export-phase2/README.md` tracker — A1 row 標 shipped + commit hash
- [ ] requirement-understanding.md §6 階段表加 `✅ shipped (commit, date)`
- [ ] 通知 user A1 ship

---

## Test Coverage 彙總

| 層 | 範圍 | 預估 case 數 |
|----|-----|------------|
| Generator base unit test | lookup builder / DataValidation / styles | 8-10 |
| Blank mode integration | 9 sheet + lookups + tenant scope | 8-10 |
| Filled mode (非 07) | parties / devices / info_systems / leveraged / ref_docs | 6-8 |
| Controls sheet (07) | profile-scoped + 父子 row + 已填預填 | 5-7 |
| E2E (compliance-manager-test) | 2 scenarios（blank + filled）| 2 |
| **總計** | — | **30+** |

---

## Risks Tracking

| 風險 | 來源 | 緩解策略 |
|------|-----|---------|
| Task 0 verify 假設不符 | design.md 假設 | 立刻修 design.md，不照舊做 |
| openpyxl DefinedName API 版本差異 | Task 0.6 | 已先 verify；失敗改 `wb.create_named_range` |
| profile_controls i18n / title 欄位不存在 | Task 5 | Task 5 動工前 verify entity shape |
| BE smoke 撞 jedi-issue env 問題 | A0.1 已知 | 一樣做 partial smoke（DI + service constructor）|
| FE MF 詳細頁路徑不確定 | Task 6 | Task 6 開工先 grep FE 確認路徑 |
| profile 1000+ controls 樣板太大 | design §12 | 第一版接受，反映再優化 |

---

## 不在 A1 範圍（明確排除）

- ❌ Excel 上傳 / parse_uid / parse_result（A2）
- ❌ 鉤稽 logic（A3, A4）
- ❌ 預覽 / Confirm 寫入（A5）
- ❌ Docx 樣板 / generator（B1）
- ❌ 控制項 mandatory flag（系統無此概念）

---

## 完成後 handover 給下個 phase

- 樣板版本欄位（`00_說明` R1）正式以 `"v1.0.0"` 標示，**A2 parser 開工時讀此欄位作為向後相容性 anchor**
- 樣板 sheet 結構 frozen at `design-A1.md §4`，後續變動需 bump 版本 + A2 parser 同步
- 07_控制項與AO sheet 父子 row 結構（statement_id 空=父 control / 有=子 AO + 兩者都帶 control_id）是 A2 parser 解析的 contract，請參考 Task 5
