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

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


§1

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 並行進行。


§2

Task 0 — Pre-flight Verification

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

0.1 verify MF ↔︎ system_characteristic 鉤稽路徑

# 確認 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 取法

# 看 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 取法

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 範圍

# 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' 支援

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

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


§3

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__.pycreate_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 範例

# 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


§4

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 核心抽象

# 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

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

預計 8-10 個 unit test。


§5

Task 3 — Blank Mode 完整實作

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

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

@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 路徑

@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

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


§6

Task 4 — Filled Mode 資料組裝(非 07 控制項 sheet)

4.1 app_service._populate_filled_data

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 寫入

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

預計 6-8 個 unit test。


§7

Task 5 — 07_控制項與AO Sheet(profile-scoped + filled)

5.1 核心邏輯

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 視覺標示

# 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

預計 5-7 個 unit test。


§8

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

// 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


§9

Task 7 — E2E + Smoke + Changelog

7.1 BE smoke

# 起 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

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

---
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 收尾


§10

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+

§11

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 第一版接受,反映再優化

§12

不在 A1 範圍(明確排除)

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

§13

完成後 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