Module Frame Template Defaults — 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: 把「現況說明 + 程序書」抽成 module_frame 層的模板預設值;啟動專案時 UPDATE-in-place 進 SSP,避免每個專案重複填寫。

Architecture: Component Definition Lite — 在 compliance schema 新增 4 張表(control defaults / objective defaults / reference documents / mappings),對應 SSP 端結構。啟動專案時對 Step 7.5 已建好的空殼 SSP rows 做 UPDATE in place(不 INSERT,避免 unique constraint 衝突)。並修補 _clone_ssp_objectives 既有 bug 確保 round 2 程序書不消失。

Tech Stack: Python 3.11 / Flask-RESTful / SQLAlchemy / dependency-injector / PostgreSQL / pytest / Vue 3 + PrimeVue / vue-i18n

Spec reference: docs/features/FR-018-2604-module-frame-template-defaults/design.md


§1

File Structure

Backend — New Files

SQL migration:

  • scripts/sql/2026-04-25-module-frame-template-defaults.sql

Domain layer (4 entity sets):

  • domain/module_frame/entity/module_frame_control_default_entity.py + _query_entity.py
  • domain/module_frame/entity/module_frame_control_objective_default_entity.py + _query_entity.py
  • domain/module_frame/entity/module_frame_reference_document_entity.py + _query_entity.py
  • domain/module_frame/entity/module_frame_reference_document_mapping_entity.py + _query_entity.py
  • domain/module_frame/repository/module_frame_control_default_repo.py (interface)
  • domain/module_frame/repository/module_frame_control_objective_default_repo.py
  • domain/module_frame/repository/module_frame_reference_document_repo.py
  • domain/module_frame/repository/module_frame_reference_document_mapping_repo.py
  • domain/module_frame/service/module_frame_control_default_domain_service.py
  • domain/module_frame/service/module_frame_control_objective_default_domain_service.py
  • domain/module_frame/service/module_frame_reference_document_domain_service.py
  • domain/module_frame/service/module_frame_reference_document_mapping_domain_service.py

Infra layer:

  • infra/module_frame/models/module_frame_control_default.py
  • infra/module_frame/models/module_frame_control_objective_default.py
  • infra/module_frame/models/module_frame_reference_document.py
  • infra/module_frame/models/module_frame_reference_document_mapping.py
  • infra/module_frame/mapper/module_frame_control_default_mapper.py
  • infra/module_frame/mapper/module_frame_control_objective_default_mapper.py
  • infra/module_frame/mapper/module_frame_reference_document_mapper.py
  • infra/module_frame/mapper/module_frame_reference_document_mapping_mapper.py
  • infra/module_frame/repository/module_frame_control_default_repo_impl.py
  • infra/module_frame/repository/module_frame_control_objective_default_repo_impl.py
  • infra/module_frame/repository/module_frame_reference_document_repo_impl.py
  • infra/module_frame/repository/module_frame_reference_document_mapping_repo_impl.py

App layer:

  • app/module_frame/dto/module_frame_control_default_dto.py
  • app/module_frame/dto/module_frame_control_objective_default_dto.py
  • app/module_frame/dto/module_frame_reference_document_dto.py
  • app/module_frame/service/module_frame_control_default_service.py
  • app/module_frame/service/module_frame_control_objective_default_service.py
  • app/module_frame/service/module_frame_reference_document_service.py
  • app/module_frame/service/module_frame_template_copy_service.py (核心對拷邏輯)
  • app/module_frame/service/module_frame_template_import_service.py (Excel 批次)

API layer:

  • api/module_frame/serializers/module_frame_control_default.py
  • api/module_frame/serializers/module_frame_control_objective_default.py
  • api/module_frame/serializers/module_frame_reference_document.py
  • api/module_frame/routes/module_frame_control_default_route.py
  • api/module_frame/routes/module_frame_control_objective_default_route.py
  • api/module_frame/routes/module_frame_reference_document_route.py
  • api/module_frame/routes/module_frame_reference_document_mapping_route.py
  • api/module_frame/routes/module_frame_template_import_route.py
  • api/oscal/routes/catalog_control_assessment_route.py(如已有則跳過)

Common / Containers:

  • common/code/module_frame_error_code.py(新檔)

Tests:

  • test/test_module_frame_control_default_service.py
  • test/test_module_frame_control_objective_default_service.py
  • test/test_module_frame_reference_document_service.py
  • test/test_module_frame_template_copy_service.py(最重要)
  • test/test_module_frame_template_import_service.py
  • test/test_oscal_project_service_template_copy_integration.py
  • test/test_clone_ssp_objectives_reference_documents.py

Backend — Modify

  • infra/module_frame/models/module_frame.py:加 relationships(optional cascade)
  • app/project/service/oscal_project_service.py
    • start_oscal_project() 加呼叫 _copy_template_to_ssp()
    • _clone_ssp_objectives() 補一行 copy reference_documents (§5.5)
  • di_containers/module_frame/module_frame_containers.py:註冊新 services + repos
  • di_containers/project/...:inject module_frame_template_copy_serviceOscalProjectService

jedi-oscal package — Modify

  • jedi_oscal/infra/model/ssp/ssp.py:加 template_module_frame_id 欄位(nullable Integer)
  • jedi_oscal/infra/mapper/ssp/ssp_mapper.py:round-trip 新欄位
  • jedi_oscal/domain/entity/ssp/ssp_entity.py:加新欄位
  • 進版(minor bump)+ 發佈 Nexus

Frontend — New Files

  • compliance-manager-fe/src/views/module_frame/ModuleFrameTemplateEditView.vue
  • compliance-manager-fe/src/components/grc/ModuleFrameTemplateImportDialog.vue(fork SspImportDialog.vue
  • compliance-manager-fe/src/service/ModuleFrameTemplateService.js

Frontend — Modify

  • compliance-manager-fe/src/views/module_frame/ModuleFrame.vue:列表加「編輯預設值」按鈕、建立完跳轉
  • compliance-manager-fe/src/config/api/api.js:加新 endpoint 常數
  • compliance-manager-fe/src/config/router/index.js:加 /module-frame/:uid/template-edit route
  • compliance-manager-fe/src/config/locales/i18n/zh-tw/module-frame.json:新 key
  • compliance-manager-fe/src/config/locales/i18n/en/module-frame.json:新 key

§2

Task Sequence Overview

Phase 0: jedi-oscal 進版(前置阻擋)
  Task 0

Phase 1: BE Schema + DDD 基礎層
  Task 1: SQL migration
  Task 2: ORM Models (4)
  Task 3: Domain Entities + Repo Interfaces (4 sets)
  Task 4: Infra Mappers + Repo Impls (4 sets)
  Task 5: Domain Services (4)

Phase 2: BE App Services
  Task 6: ErrorCode + Control Default Service
  Task 7: Objective Default Service
  Task 8: Reference Document Service (含 mappings)
  Task 9: Template Copy Service ⭐ 核心
  Task 10: Template Import Service (Excel)

Phase 3: BE API
  Task 11: Serializers + Control Default Routes
  Task 12: Objective Default Routes
  Task 13: Reference Document + Mapping Routes
  Task 14: Template Import/Export Routes
  Task 15: Catalog AO 列舉 endpoint
  Task 16: DI Containers Wiring

Phase 4: BE 整合
  Task 17: Hook into start_oscal_project + Fix _clone_ssp_objectives ⭐ 關鍵
  Task 18: Backward compat 整合測試 (空模板 + 既有專案啟動)
  Task 19: Golden path 整合測試 + Round 2 程序書 survival 測試

Phase 5: FE Service + 路由
  Task 20: ModuleFrameTemplateService.js + api.js + router + i18n

Phase 6: FE 詳情頁
  Task 21: TemplateEditView 骨架 + Tree
  Task 22: 控制項詳情編輯 + 自動儲存
  Task 23: AO Accordion 編輯
  Task 24: 程序書區塊(reuse Planning 元件)
  Task 25: Stats bar + Header

Phase 7: FE 批次 + 入口
  Task 26: Fork SspImportDialog → ModuleFrameTemplateImportDialog
  Task 27: ModuleFrame 列表入口 + 跳轉

Phase 8: 收尾
  Task 28: Manual e2e 驗證 + 文件交付 (changelog)

§3

Task 0: jedi-oscal 加 template_module_frame_id 欄位

Files (jedi-oscal package, 路徑相對於 ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/):

  • Modify: jedi_oscal/infra/model/ssp/ssp.py
  • Modify: jedi_oscal/infra/mapper/ssp/ssp_mapper.py
  • Modify: jedi_oscal/domain/entity/ssp/ssp_entity.py
  • Modify: pyproject.toml (進版 0.0.X → 0.0.X+1)

ssp.py 新增:

template_module_frame_id: Mapped[Optional[int]] = mapped_column(
    Integer,
    nullable=True,
    comment="此 SSP 的源頭 module_frame ID(template lineage tracking)",
)

ssp_mapper.pyto_entitytemplate_module_frame_id=getattr(model, 'template_module_frame_id', None)to_modelm.template_module_frame_id = getattr(entity, 'template_module_frame_id', None)

ssp_entity.py OscalSystemSecurityPlanEntitytemplate_module_frame_id: Optional[int] = None

pyproject.toml 把版本號 minor bump(如 0.0.80.0.9)。等使用者明確指示後才發 Nexus(按 CLAUDE.md feedback_jedi_package_publish_flow)。

在 jedi-oscal repo 加 tests/test_ssp_template_module_frame_id.py

def test_ssp_template_module_frame_id_round_trip(session):
    # Arrange: create SSP with template_module_frame_id=42
    ssp = OscalSystemSecurityPlan(
        profile_id=1, metadata_id=1, document_id=1, status='draft',
        template_module_frame_id=42,
    )
    session.add(ssp); session.commit()

    # Act: reload
    reloaded = session.query(OscalSystemSecurityPlan).filter_by(id=ssp.id).first()

    # Assert: column persists
    assert reloaded.template_module_frame_id == 42

    # Mapper round-trip
    entity = SspMapper.to_entity(reloaded)
    assert entity.template_module_frame_id == 42
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal
pytest tests/test_ssp_template_module_frame_id.py -v

Expected: PASS

cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
python -c "from jedi_oscal.infra.model.ssp.ssp import OscalSystemSecurityPlan; print('template_module_frame_id' in OscalSystemSecurityPlan.__table__.c)"

Expected: True

cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal
git add jedi_oscal/infra/model/ssp/ssp.py jedi_oscal/infra/mapper/ssp/ssp_mapper.py jedi_oscal/domain/entity/ssp/ssp_entity.py pyproject.toml
git commit -m "feat(ssp): add template_module_frame_id for module_frame lineage"

§4

Task 1: SQL Migration(4 新表 + ALTER + GRANT)

Files:

  • Create: scripts/sql/2026-04-25-module-frame-template-defaults.sql

完整內容:

-- Date: 2026-04-25
-- Module Frame Template Defaults — 控制項/AO 預設值 + 程序書池

-- 1. 控制項層預設值 (2026-04-25)
CREATE TABLE IF NOT EXISTS compliance.module_frame_control_defaults (
    id                          SERIAL PRIMARY KEY,
    uid                         UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
    module_frame_id             INTEGER NOT NULL
        REFERENCES public.module_frames(id) ON DELETE CASCADE,
    control_identifier          VARCHAR(100) NOT NULL,
    implementation_status       VARCHAR(30) DEFAULT 'unknown',
    implementation_description  TEXT,
    responsible_role            VARCHAR(100),
    control_origination         VARCHAR(50) DEFAULT 'organization',
    remarks                     TEXT,
    created_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user                VARCHAR(50),
    updated_user                VARCHAR(50),
    CONSTRAINT uq_mfcd_module_frame_control UNIQUE (module_frame_id, control_identifier)
);
CREATE INDEX IF NOT EXISTS ix_mfcd_module_frame_id
    ON compliance.module_frame_control_defaults (module_frame_id);

-- 2. AO 層預設值 (2026-04-25)
CREATE TABLE IF NOT EXISTS compliance.module_frame_control_objective_defaults (
    id                          SERIAL PRIMARY KEY,
    uid                         UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
    module_frame_id             INTEGER NOT NULL
        REFERENCES public.module_frames(id) ON DELETE CASCADE,
    control_default_id          INTEGER NOT NULL
        REFERENCES compliance.module_frame_control_defaults(id) ON DELETE CASCADE,
    control_identifier          VARCHAR(100) NOT NULL,
    statement_identifier        VARCHAR(100) NOT NULL,
    implementation_status       VARCHAR(50),
    implementation_description  TEXT,
    remarks                     TEXT,
    created_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user                VARCHAR(50),
    updated_user                VARCHAR(50),
    CONSTRAINT uq_mfcod_ctrl_default_stmt UNIQUE (control_default_id, statement_identifier)
);
CREATE INDEX IF NOT EXISTS ix_mfcod_module_frame_id
    ON compliance.module_frame_control_objective_defaults (module_frame_id);
CREATE INDEX IF NOT EXISTS ix_mfcod_control_default_id
    ON compliance.module_frame_control_objective_defaults (control_default_id);

-- 3. 程序書池 (2026-04-25)
CREATE TABLE IF NOT EXISTS compliance.module_frame_reference_documents (
    id                  SERIAL PRIMARY KEY,
    uid                 VARCHAR(36) DEFAULT gen_random_uuid() NOT NULL UNIQUE,
    module_frame_id     INTEGER NOT NULL
        REFERENCES public.module_frames(id) ON DELETE CASCADE,
    file_id             INTEGER NOT NULL,
    title               VARCHAR(255),
    description         TEXT,
    created_at          TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at          TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user        VARCHAR(50),
    updated_user        VARCHAR(50)
);
CREATE INDEX IF NOT EXISTS ix_mfrd_module_frame_id
    ON compliance.module_frame_reference_documents (module_frame_id);
CREATE INDEX IF NOT EXISTS ix_mfrd_file_id
    ON compliance.module_frame_reference_documents (file_id);

-- 4. 程序書多對多 mappings (2026-04-25)
CREATE TABLE IF NOT EXISTS compliance.module_frame_reference_document_mappings (
    id                          SERIAL PRIMARY KEY,
    reference_document_id       INTEGER NOT NULL
        REFERENCES compliance.module_frame_reference_documents(id) ON DELETE CASCADE,
    context_type                VARCHAR(30) NOT NULL,
    context_id                  INTEGER NOT NULL,
    created_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user                VARCHAR(50),
    CONSTRAINT uq_mfrdm_doc_context UNIQUE (reference_document_id, context_type, context_id),
    CONSTRAINT chk_mfrdm_context_type CHECK (context_type IN ('control_default', 'objective_default'))
);
CREATE INDEX IF NOT EXISTS ix_mfrdm_context
    ON compliance.module_frame_reference_document_mappings (context_type, context_id);
CREATE INDEX IF NOT EXISTS ix_mfrdm_doc_id
    ON compliance.module_frame_reference_document_mappings (reference_document_id);

-- 5. SSP 加 template lineage 欄位 (2026-04-25)
ALTER TABLE oscal.system_security_plans
    ADD COLUMN IF NOT EXISTS template_module_frame_id INTEGER NULL;

-- 6. 權限授予 cm_app (2026-04-25)
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_control_defaults TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_control_defaults_id_seq TO cm_app;

GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_control_objective_defaults TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_control_objective_defaults_id_seq TO cm_app;

GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_reference_documents TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_reference_documents_id_seq TO cm_app;

GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_reference_document_mappings TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_reference_document_mappings_id_seq TO cm_app;
psql $DB_URL -f scripts/sql/2026-04-25-module-frame-template-defaults.sql

Expected: 4 CREATE TABLE + 1 ALTER + GRANT 全部成功,無錯誤

psql $DB_URL -c "\d compliance.module_frame_control_defaults"
psql $DB_URL -c "SELECT has_table_privilege('cm_app', 'compliance.module_frame_control_defaults', 'INSERT');"

Expected: 表結構正確,權限 = t

psql $DB_URL -c "\di oscal.assessment_plan_tasks*"

Expected: 有 index on catalog_control_assessment_id。若無則加 CREATE INDEX IF NOT EXISTS ix_ap_task_ccai_id ON oscal.assessment_plan_tasks (catalog_control_assessment_id); 到 migration

git add scripts/sql/2026-04-25-module-frame-template-defaults.sql
git commit -m "feat(db): module_frame template defaults schema (4 tables + SSP column)"

§5

Task 2: ORM Models(4 張新表)

Files:

  • Create: infra/module_frame/models/module_frame_control_default.py
  • Create: infra/module_frame/models/module_frame_control_objective_default.py
  • Create: infra/module_frame/models/module_frame_reference_document.py
  • Create: infra/module_frame/models/module_frame_reference_document_mapping.py

infra/module_frame/models/module_frame.pyinfra/grc/model/ssp_reference_document_mapping.py(如果有),確認 schema 命名、ENUM 用法、TenantScopedMixinModel 是否需要。

→ 新表透過 module_frame_id FK 間接綁 tenant,不繼承 TenantScopedMixinModel。

每個 model 對應 §3.1 的 schema。範例 module_frame_control_default.py

import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import Integer, String, Text, ForeignKey, UUID, DateTime, func, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column
from jedi_common.session.database.declarative_base import Base


class ModuleFrameControlDefault(Base):
    __tablename__ = "module_frame_control_defaults"
    __table_args__ = (
        UniqueConstraint("module_frame_id", "control_identifier", name="uq_mfcd_module_frame_control"),
        Index("ix_mfcd_module_frame_id", "module_frame_id"),
        {"schema": "compliance"},
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    uid: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
    module_frame_id: Mapped[int] = mapped_column(ForeignKey("public.module_frames.id", ondelete="CASCADE"), nullable=False)
    control_identifier: Mapped[str] = mapped_column(String(100), nullable=False)
    implementation_status: Mapped[Optional[str]] = mapped_column(String(30), default="unknown")
    implementation_description: Mapped[Optional[str]] = mapped_column(Text)
    responsible_role: Mapped[Optional[str]] = mapped_column(String(100))
    control_origination: Mapped[Optional[str]] = mapped_column(String(50), default="organization")
    remarks: Mapped[Optional[str]] = mapped_column(Text)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), nullable=False)
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), onupdate=func.now(), nullable=False)
    created_user: Mapped[Optional[str]] = mapped_column(String(50))
    updated_user: Mapped[Optional[str]] = mapped_column(String(50))

其他三個 model 結構類似(按 §3.1.2-4 schema)。module_frame_reference_document.uidString(36) 沿用 SSP convention。

ENV=DEVELOP_PREMISE python -c "from infra.module_frame.models.module_frame_control_default import ModuleFrameControlDefault; print(ModuleFrameControlDefault.__table__)"

Expected: 印出 table object,沒有 SQLAlchemy 錯誤

git add infra/module_frame/models/
git commit -m "feat(infra): module_frame template defaults ORM models"

§6

Task 3: Domain Entities + Repo Interfaces

Files:

  • Create: domain/module_frame/entity/module_frame_control_default_entity.py + _query_entity.py
  • Create: domain/module_frame/entity/module_frame_control_objective_default_entity.py + _query_entity.py
  • Create: domain/module_frame/entity/module_frame_reference_document_entity.py + _query_entity.py
  • Create: domain/module_frame/entity/module_frame_reference_document_mapping_entity.py + _query_entity.py
  • Create: domain/module_frame/repository/module_frame_control_default_repo.py
  • Create: domain/module_frame/repository/module_frame_control_objective_default_repo.py
  • Create: domain/module_frame/repository/module_frame_reference_document_repo.py
  • Create: domain/module_frame/repository/module_frame_reference_document_mapping_repo.py

domain/module_frame/entity/module_frame_entity.pymodule_frame_query_entity.py,跟著模式建立。

*_entity.py(dataclass,full record)+ *_query_entity.py(dataclass,optional fields for filtering)。共 8 個檔案。

抽象 method 至少包含:

  • get_one(query_entity) → entity 或 None
  • get_all(query_entity) → list[entity]
  • add(entity) → entity
  • update(entity) → entity
  • delete_by_id(id) → bool
  • bulk_add(entities) → list[entity](給對拷用,optional)
git add domain/module_frame/entity/ domain/module_frame/repository/
git commit -m "feat(domain): module_frame template defaults entities + repo interfaces"

§7

Task 4: Infra Mappers + Repo Impls

Files:

  • Create: infra/module_frame/mapper/module_frame_control_default_mapper.py + 3 others
  • Create: infra/module_frame/repository/module_frame_control_default_repo_impl.py + 3 others

infra/module_frame/mapper/module_frame_mapper.py + infra/module_frame/repository/module_frame_repo_impl.py

雙向 entity ↔︎ model(to_entity + to_model + to_list_entity)。

實作 interface 所有 method,使用 self.session禁止 cross-schema FK constraint,跨 schema 查詢用 Integer soft reference。

git add infra/module_frame/mapper/ infra/module_frame/repository/
git commit -m "feat(infra): module_frame template defaults mappers + repo impls"

§8

Task 5: Domain Services

Files:

  • Create: domain/module_frame/service/module_frame_control_default_domain_service.py
  • Create: domain/module_frame/service/module_frame_control_objective_default_domain_service.py
  • Create: domain/module_frame/service/module_frame_reference_document_domain_service.py
  • Create: domain/module_frame/service/module_frame_reference_document_mapping_domain_service.py

domain/module_frame/service/module_frame_domain_service.py

每個包含薄封裝(轉發 query_entity 給 repo)。集中業務驗證但涉及 SQL。

git add domain/module_frame/service/
git commit -m "feat(domain): module_frame template defaults domain services"

§9

Task 6: ErrorCode + Control Default App Service

Files:

  • Create: common/code/module_frame_error_code.py
  • Create: app/module_frame/dto/module_frame_control_default_dto.py
  • Create: app/module_frame/service/module_frame_control_default_service.py
  • Test: test/test_module_frame_control_default_service.py

按 CLAUDE.md 命名規範:

from jedi_common.enums.base_code import BaseCode

class ModuleFrameErrorCode(BaseCode):
    MODULE_FRAME_CONTROL_DEFAULT_NOT_FOUND   = ("模板控制項預設值不存在", "MODULE_FRAME_404001")
    MODULE_FRAME_OBJECTIVE_DEFAULT_NOT_FOUND = ("模板 AO 預設值不存在",   "MODULE_FRAME_404002")
    MODULE_FRAME_REFERENCE_DOC_NOT_FOUND     = ("模板程序書不存在",        "MODULE_FRAME_404003")
    MODULE_FRAME_CONTROL_NOT_IN_PROFILE      = ("控制項不屬於此模板的 profile", "MODULE_FRAME_400001")
    MODULE_FRAME_OBJECTIVE_NOT_VALID         = ("AO 不屬於該控制項",       "MODULE_FRAME_400002")

ModuleFrameControlDefaultDto:含 uid / control_identifier / implementation_status / implementation_description / responsible_role / control_origination / remarks + 審計欄位。

test/test_module_frame_control_default_service.py

def test_upsert_creates_row_when_not_exists():
    # Arrange: existing module_frame with a profile containing AC.L1-3.1.1
    # Act: service.upsert(uid, "AC.L1-3.1.1", payload)
    # Assert: row exists in module_frame_control_defaults

def test_upsert_updates_row_when_exists():
    # Arrange: existing default row
    # Act: service.upsert(uid, control_id, new payload)
    # Assert: row updated, no duplicate

def test_upsert_raises_when_control_not_in_profile():
    # Act + Assert: raises BadRequestError(MODULE_FRAME_CONTROL_NOT_IN_PROFILE)

執行:

pytest test/test_module_frame_control_default_service.py -v

Expected: FAIL(service 還沒寫)

module_frame_control_default_service.py — 含 @transaction 裝飾器、權限檢查(manager 角色)、profile 驗證、upsert 邏輯。

pytest test/test_module_frame_control_default_service.py -v

Expected: 3 PASSED

git add common/code/module_frame_error_code.py app/module_frame/dto/ app/module_frame/service/module_frame_control_default_service.py test/test_module_frame_control_default_service.py
git commit -m "feat(app): module_frame control default service + tests"

§10

Task 7: Objective Default App Service

Files:

  • Create: app/module_frame/dto/module_frame_control_objective_default_dto.py
  • Create: app/module_frame/service/module_frame_control_objective_default_service.py
  • Test: test/test_module_frame_control_objective_default_service.py
def test_upsert_objective_default_creates_row()
def test_upsert_validates_statement_identifier_belongs_to_control()  # ccai must exist for that control
def test_upsert_raises_when_control_default_not_exists()
def test_delete_objective_default()
def test_list_by_module_frame()

PUT body 含 control_identifier + statement_identifier(catalog_control_assessment.uid)。Service 驗證:

  1. module_frame 存在
  2. control_default 存在(如沒有,先 lazy 建一筆)
  3. statement_identifier 是該 control 的 catalog_control_assessment.uid
  4. Upsert objective_default
git add app/module_frame/dto/module_frame_control_objective_default_dto.py app/module_frame/service/module_frame_control_objective_default_service.py test/test_module_frame_control_objective_default_service.py
git commit -m "feat(app): module_frame objective default service + tests"

§11

Task 8: Reference Document App Service(含 mappings)

Files:

  • Create: app/module_frame/dto/module_frame_reference_document_dto.py
  • Create: app/module_frame/service/module_frame_reference_document_service.py
  • Test: test/test_module_frame_reference_document_service.py
def test_upload_file_creates_doc_in_pool()
def test_attach_doc_to_control_default()
def test_attach_doc_to_objective_default()
def test_detach_mapping_keeps_doc_in_pool()
def test_delete_doc_cascades_mappings_but_keeps_file()
def test_list_pool_includes_mapping_count()

包含:

  • upload(module_frame_uid, file, meta) — 走既有 jedi-file-upload 拿 file_id,INSERT 一筆 module_frame_reference_documents
  • attach_to_control_default(...) / attach_to_objective_default(...) — INSERT mappings
  • detach(...) — DELETE 單筆 mapping(不刪 doc)
  • delete_doc(doc_uid) — DELETE doc(CASCADE mappings)
  • list_pool(module_frame_uid) — 查池 + 每筆 mapping count
git add app/module_frame/dto/module_frame_reference_document_dto.py app/module_frame/service/module_frame_reference_document_service.py test/test_module_frame_reference_document_service.py
git commit -m "feat(app): module_frame reference document service + mappings + tests"

§12

Task 9: Template Copy Service ⭐ 核心對拷邏輯

Files:

  • Create: app/module_frame/service/module_frame_template_copy_service.py
  • Test: test/test_module_frame_template_copy_service.py

按 design.md §5.0~§5.5。

test_module_frame_template_copy_service.py

def test_copy_with_empty_template_is_noop():
    # Arrange: module_frame 無任何 defaults; SSP 已被 Step 7.5 建好空殼
    # Act: copy_service.copy(module_frame_id, ssp_id, ap_id, "tester")
    # Assert: SSP control_impl + objectives 內容完全與 Step 7.5 後一致(NULL desc, status='unknown')

def test_copy_control_defaults_updates_in_place():
    # Arrange: module_frame 有 1 control_default; Step 7.5 已建空殼 ssp_control_impl
    # Act: copy()
    # Assert: ssp_control_impl 該 row 的 description / responsible_role / origination 被填上模板值
    #         row 數量沒變(沒 INSERT 新 row)

def test_copy_objective_defaults_updates_in_place():
    # 同上但 AO 層
    # 關鍵:用 COALESCE(task_code, title, id::text) 對應 statement_identifier

def test_copy_objective_skips_controls_without_ccai():
    # Arrange: 一個 control 在 catalog 端沒有 catalog_control_assessment(fallback "Evidence upload" path)
    # Act: copy()
    # Assert: 沒有 AO row 被影響;log 出現 INFO「N controls had no CCA」

def test_copy_control_reference_documents_creates_ssp_mapping_rows():
    # 測 Step 3a (mappings 表)

def test_copy_objective_reference_documents_writes_jsonb():
    # 測 Step 3b (JSONB inline)

def test_copy_marks_ssp_template_module_frame_id():
    # Assert: ssp.template_module_frame_id == module_frame_id after copy

def test_copy_failure_rollback_via_transaction():
    # 故意讓某一步失敗,驗證整 transaction 回滾

def test_copy_reference_document_shares_same_file_id():
    # 對應 design.md §5.3:file_id 共享,不複製檔案本體
    # Arrange: module_frame_reference_documents.file_id = 999
    # Act: copy()
    # Assert: oscal.ssp_reference_documents.file_id == 999 (not a new file_id)

執行:

pytest test/test_module_frame_template_copy_service.py -v

Expected: ALL FAIL

module_frame_template_copy_service.py

from sqlalchemy import text
from jedi_common.session.database.session_context import get_session
from jedi_common.handler.exception import BadRequestError, NotFound
from common.code.module_frame_error_code import ModuleFrameErrorCode
import logging

logger = logging.getLogger(__name__)


class ModuleFrameTemplateCopyService:
    """
    對 Step 7.5 已建好的 SSP 空殼 row 做 UPDATE in place。
    不 INSERT 任何 control_impl / objective row。

    重要:本 service 必須在 @transaction-decorated method 裡呼叫
    (session 由 jedi_common get_session() 取得,與呼叫端共享 transaction scope)
    """

    def __init__(self, module_frame_control_default_domain_service,
                 module_frame_control_objective_default_domain_service,
                 module_frame_reference_document_domain_service,
                 module_frame_reference_document_mapping_domain_service):
        self._ctrl_default_ds = module_frame_control_default_domain_service
        self._obj_default_ds = module_frame_control_objective_default_domain_service
        self._ref_doc_ds = module_frame_reference_document_domain_service
        self._ref_doc_mapping_ds = module_frame_reference_document_mapping_domain_service

    def copy(self, module_frame_id: int, ssp_id: int, ap_id: int, curr_user: str) -> dict:
        """
        Returns: {"controls_updated": N, "objectives_updated": M, "ref_docs_copied": K}
        Raises: 任何 SQL/驗證錯誤都 raise 出去,由呼叫端 @transaction rollback
        """
        session = get_session()

        # Step 1: 控制項層 UPDATE in place
        ctrl_result = session.execute(text("""
            UPDATE oscal.system_security_plan_control_implementations ci
            SET
              implementation_status = COALESCE(mfcd.implementation_status, ci.implementation_status),
              implementation_description = COALESCE(mfcd.implementation_description, ci.implementation_description),
              responsible_role = COALESCE(mfcd.responsible_role, ci.responsible_role),
              control_origination = COALESCE(mfcd.control_origination, ci.control_origination),
              remarks = COALESCE(mfcd.remarks, ci.remarks),
              updated_user = :curr_user,
              updated_at = now()
            FROM compliance.module_frame_control_defaults mfcd
            WHERE mfcd.module_frame_id = :mf_id
              AND ci.system_security_plan_id = :ssp_id
              AND ci.control_identifier = mfcd.control_identifier
        """), {"mf_id": module_frame_id, "ssp_id": ssp_id, "curr_user": curr_user})
        controls_updated = ctrl_result.rowcount

        # Step 2: AO 層 UPDATE in place
        obj_result = session.execute(text("""
            UPDATE oscal.ssp_control_implementation_objectives obj
            SET
              implementation_status = COALESCE(mfod.implementation_status, obj.implementation_status),
              implementation_description = COALESCE(mfod.implementation_description, obj.implementation_description),
              remarks = COALESCE(mfod.remarks, obj.remarks),
              updated_user = :curr_user,
              updated_at = now()
            FROM compliance.module_frame_control_objective_defaults mfod
            JOIN oscal.catalog_control_assessments ccai
              ON ccai.uid::text = mfod.statement_identifier
            JOIN oscal.assessment_plan_tasks ap_task
              ON ap_task.catalog_control_assessment_id = ccai.id
             AND ap_task.assessment_plan_id = :ap_id
            WHERE mfod.module_frame_id = :mf_id
              AND obj.system_security_plan_id = :ssp_id
              AND obj.control_identifier = mfod.control_identifier
              AND obj.statement_identifier = COALESCE(ap_task.task_code, ap_task.title, ap_task.id::text)
        """), {"mf_id": module_frame_id, "ssp_id": ssp_id, "ap_id": ap_id, "curr_user": curr_user})
        objectives_updated = obj_result.rowcount

        # Step 3a: 控制項層程序書 → ssp_reference_documents + mappings
        self._copy_control_reference_documents(session, module_frame_id, ssp_id, curr_user)

        # Step 3b: AO 層程序書 → ssp_control_implementation_objectives.reference_documents JSONB
        self._copy_objective_reference_documents_to_jsonb(session, module_frame_id, ssp_id, ap_id, curr_user)

        # Step 4: 標記 SSP 來源
        session.execute(text("""
            UPDATE oscal.system_security_plans
               SET template_module_frame_id = :mf_id
             WHERE id = :ssp_id
        """), {"mf_id": module_frame_id, "ssp_id": ssp_id})

        # Log fallback skip (沒對應的 control 是因為 control 沒 catalog_control_assessment, fallback Evidence Upload path)
        expected_count = session.execute(text(
            "SELECT count(*) FROM compliance.module_frame_control_defaults WHERE module_frame_id = :mf_id"
        ), {"mf_id": module_frame_id}).scalar()
        if controls_updated < expected_count:
            logger.info(
                f"[template_copy] module_frame={module_frame_id}: "
                f"{expected_count - controls_updated} of {expected_count} control defaults skipped "
                f"(SSP control_impl not found - likely fallback Evidence Upload path)"
            )

        return {"controls_updated": controls_updated, "objectives_updated": objectives_updated}

    def _copy_control_reference_documents(self, session, module_frame_id, ssp_id, curr_user):
        """控制項層程序書 → SSP mappings 表(INSERT 新 row)"""
        # 步驟:
        # 1. 查模板 control 層 mappings:
        #    SELECT mfrd.file_id, mfrd.title, mfrd.description, mfcd.control_identifier
        #    FROM module_frame_reference_document_mappings mfrdm
        #    JOIN module_frame_reference_documents mfrd ON mfrd.id = mfrdm.reference_document_id
        #    JOIN module_frame_control_defaults mfcd ON mfcd.id = mfrdm.context_id
        #    WHERE mfrdm.context_type = 'control_default' AND mfrd.module_frame_id = :mf_id
        # 2. 對每筆,找對應的 ssp_control_impl.id (by control_identifier + ssp_id)
        # 3. INSERT INTO oscal.ssp_reference_documents (context_type='control_implementation',
        #       context_id=ssp_ci.id, file_id=mfrd.file_id, description=mfrd.description, ...)
        # 4. 取得新 ssp_ref_doc.id, INSERT INTO oscal.ssp_reference_document_mappings
        #    (context_type='control_implementation', context_id=ssp_ci.id, reference_document_id=新 ref_doc.id)
        pass  # 實作時填入

    def _copy_objective_reference_documents_to_jsonb(self, session, module_frame_id, ssp_id, ap_id, curr_user):
        """AO 層程序書 → ssp_control_implementation_objectives.reference_documents JSONB(UPDATE in place)"""
        # 步驟:
        # 1. 查模板 AO 層 mappings:
        #    SELECT mfod.statement_identifier (=ccai.uid), mfod.control_identifier,
        #           json_agg(json_build_object('file_id', mfrd.file_id, 'name', mfrd.title)) AS docs
        #    FROM module_frame_reference_document_mappings mfrdm
        #    JOIN module_frame_reference_documents mfrd ON mfrd.id = mfrdm.reference_document_id
        #    JOIN module_frame_control_objective_defaults mfod ON mfod.id = mfrdm.context_id
        #    WHERE mfrdm.context_type = 'objective_default' AND mfrd.module_frame_id = :mf_id
        #    GROUP BY mfod.id
        # 2. 對每筆 row,透過 ccai → ap_task → ssp_objective 對應(同 Step 2 的 join chain)
        # 3. UPDATE oscal.ssp_control_implementation_objectives obj
        #       SET reference_documents = :docs_jsonb
        #     WHERE obj.system_security_plan_id = :ssp_id
        #       AND obj.statement_identifier = COALESCE(ap_task.task_code, ap_task.title, ap_task.id::text)
        pass  # 實作時填入
pytest test/test_module_frame_template_copy_service.py -v

Expected: ALL PASSED

git add app/module_frame/service/module_frame_template_copy_service.py test/test_module_frame_template_copy_service.py
git commit -m "feat(app): module_frame template copy service (UPDATE in place + ref docs)"

§13

Task 10: Template Import Service(Excel 批次)

Files:

  • Create: app/module_frame/service/module_frame_template_import_service.py
  • Test: test/test_module_frame_template_import_service.py

app/oscal/service/ssp_control_impl_import_service.py,fork 邏輯。

def test_download_template_returns_xlsx_with_control_rows()
def test_verify_returns_diff_against_existing_defaults()
def test_import_bulk_upserts_control_defaults()
def test_import_bulk_upserts_objective_defaults_by_statement_identifier()
def test_import_validates_control_identifier_in_profile()

包括 5 個 method:download_template / download_export / verify_template / verify_data / save_import

git add app/module_frame/service/module_frame_template_import_service.py test/test_module_frame_template_import_service.py
git commit -m "feat(app): module_frame template Excel import service"

§14

Task 11: Serializers + Control Default Routes

Files:

  • Create: api/module_frame/serializers/module_frame_control_default.py
  • Create: api/module_frame/routes/module_frame_control_default_route.py
  • Test: test/test_api_module_frame_control_default_route.py
def test_put_control_default_returns_401_without_jwt():
    # PUT without Authorization header → 401
def test_put_control_default_returns_403_for_non_manager():
    # PUT with viewer role → 403 (manager 角色檢查)
def test_put_control_default_returns_400_for_control_not_in_profile():
    # PUT with control_identifier 不在 profile → 400 MODULE_FRAME_CONTROL_NOT_IN_PROFILE
def test_put_control_default_returns_200_for_valid_request():
    # PUT manager + valid control → 200, body has data envelope

按 CLAUDE.md API patterns(<Resource>RequestSchema / <Resource>ResponseSchema)。

  • ListResourceGET /module-frame/<uid>/control-defaults
  • DetailResourceGET /module-frame/<uid>/control-defaults/<control_identifier>
  • UpsertResourcePUT /module-frame/<uid>/control-defaults/<control_identifier>
  • DeleteResourceDELETE /module-frame/<uid>/control-defaults/<control_identifier>

每個 method 註冊 webargs schemas + 呼叫對應 app service。

pytest test/test_api_module_frame_control_default_route.py -v

Expected: 4 PASSED

curl -X PUT http://localhost:8000/api/1.0/module-frame/<uid>/control-defaults/AC.L1-3.1.1 \
  -H "Authorization: Bearer ..." \
  -H "Content-Type: application/json" \
  -d '{"implementation_description": "test", "responsible_role": "CISO"}'

Expected: {"code": 1, "data": {...}}

git add api/module_frame/serializers/module_frame_control_default.py api/module_frame/routes/module_frame_control_default_route.py
git commit -m "feat(api): module_frame control default routes + serializers"

§15

Task 12: Objective Default Routes

Files:

  • Create: api/module_frame/serializers/module_frame_control_objective_default.py
  • Create: api/module_frame/routes/module_frame_control_objective_default_route.py
  • GET /module-frame/<uid>/objective-defaults
  • GET /module-frame/<uid>/objective-defaults/<objective_default_uid>
  • PUT /module-frame/<uid>/objective-defaults (body 含 control_identifier + statement_identifier)
  • DELETE /module-frame/<uid>/objective-defaults/<objective_default_uid>
git add api/module_frame/serializers/module_frame_control_objective_default.py api/module_frame/routes/module_frame_control_objective_default_route.py
git commit -m "feat(api): module_frame objective default routes"

§16

Task 13: Reference Document + Mapping Routes

Files:

  • Create: api/module_frame/serializers/module_frame_reference_document.py
  • Create: api/module_frame/routes/module_frame_reference_document_route.py
  • Create: api/module_frame/routes/module_frame_reference_document_mapping_route.py
  • GET /module-frame/<uid>/reference-documents
  • POST /module-frame/<uid>/reference-documents (multipart)
  • PUT /module-frame/<uid>/reference-documents/<doc_uid>
  • DELETE /module-frame/<uid>/reference-documents/<doc_uid>
  • POST /module-frame/<uid>/control-defaults/<control_identifier>/reference-documents (掛 control)
  • DELETE /module-frame/<uid>/control-defaults/<control_identifier>/reference-documents/<doc_uid>
  • POST /module-frame/<uid>/objective-defaults/<objective_default_uid>/reference-documents
  • DELETE /module-frame/<uid>/objective-defaults/<objective_default_uid>/reference-documents/<doc_uid>
git add api/module_frame/serializers/module_frame_reference_document.py api/module_frame/routes/module_frame_reference_document_route.py api/module_frame/routes/module_frame_reference_document_mapping_route.py
git commit -m "feat(api): module_frame reference document routes + mappings"

§17

Task 14: Template Import/Export Routes

Files:

  • Create: api/module_frame/routes/module_frame_template_import_route.py
  • GET /module-frame/<uid>/control-defaults/export
  • GET /module-frame/<uid>/control-defaults/template/download
  • POST /module-frame/<uid>/control-defaults/import/template
  • POST /module-frame/<uid>/control-defaults/import/verify
  • POST /module-frame/<uid>/control-defaults/import

對齊既有 SspImportRoute

git add api/module_frame/routes/module_frame_template_import_route.py
git commit -m "feat(api): module_frame template import/export routes"

§18

Task 15: Catalog AO 列舉 Endpoint

Files:

  • Create: api/oscal/routes/catalog_control_assessment_route.py(如已有則跳過)
grep -rn "catalog.control.assessment" api/oscal/ 2>/dev/null

GET /oscal/catalog-controls/<control_uid>/assessments → 回 [{uid, name, description, version}]

底層用既有 CatalogControlAssessmentRepoImpl

git add api/oscal/routes/catalog_control_assessment_route.py
git commit -m "feat(api): catalog control assessments list endpoint"

§19

Task 16: DI Containers Wiring

Files:

  • Modify: di_containers/module_frame/module_frame_containers.py
  • Modify: config/app_modules.py(如新 service 需註冊)
  • Modify: di_containers/project/...(給 OscalProjectService inject template_copy_service)

module_frame_containers.py 加:

  • 4 個 repo providers(module_frame_control_default_repo 等)
  • 4 個 domain service providers
  • 4 個 app service providers(含 template_copy + import)

修改 OscalProjectService.__init__:加入 module_frame_template_copy_service 參數(建議放最後一個位置)+ self.module_frame_template_copy_service = module_frame_template_copy_service

所有 instantiate OscalProjectService 的位置都要更新:

grep -rn "OscalProjectService(" /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be --include="*.py" | grep -v __pycache__

預期至少會找到 di_containers/project/... 內的 providers.Singleton(OscalProjectService, ...) 宣告。為每個都加上新 dependency。

驗證:執行 python -c "from di_containers.containers import Container; c = Container(); print(c.project_container.oscal_project_service())" 應無 DI 錯誤。

ENV=DEVELOP_PREMISE python main_app.py &
sleep 3
curl http://localhost:8000/swagger-ui/ | head

Expected: Swagger 載入成功,沒有 DI 錯誤

git add di_containers/
git commit -m "feat(di): wire module_frame template default services"

§20

Task 17: Hook into start_oscal_project + Fix _clone_ssp_objectives ⭐ 關鍵

Files:

  • Modify: app/project/service/oscal_project_service.py
  • Test: test/test_clone_ssp_objectives_reference_documents.py
def test_clone_ssp_objectives_preserves_reference_documents():
    # Arrange: ssp_control_implementation_objectives row with reference_documents = [{"file_id": 1, "name": "test.pdf"}]
    # Act: launch_new_round → _clone_ssp_objectives 被執行
    # Assert: 新 round 的 objective row 的 reference_documents 等於原 row(不為空)

執行:

pytest test/test_clone_ssp_objectives_reference_documents.py -v

Expected: FAIL(因為原 code 沒 copy)

oscal_project_service.py line 623remarks=getattr(source_obj, "remarks", None),在 line 623 之後、line 624 (created_user=...) 之前 插入一行:

                reference_documents=getattr(source_obj, "reference_documents", None) or [],

修補後 line 619-628 應為:

                control_identifier=source_obj.control_identifier,
                statement_identifier=new_uid,
                implementation_status=source_obj.implementation_status,
                implementation_description=source_obj.implementation_description,
                remarks=getattr(source_obj, "remarks", None),
                reference_documents=getattr(source_obj, "reference_documents", None) or [],  # ← 新增
                created_user=curr_user,
                updated_user=curr_user,
            )
pytest test/test_clone_ssp_objectives_reference_documents.py -v

Expected: PASSED

變數 module_frame_id 已存在於 start_oscal_project line 218(透過 mf.id if mf else None 取得)。sspap 物件也已在 scope 內。

start_oscal_projectStep 7.5 except 區塊結束之後(約 line 320 之後、Step 8 之前),新增:

        # 7.6 從 module_frame template 對拷 defaults 至 SSP(UPDATE in place)
        # 注意:module_frame_id 已於 line 218 取得;ssp 跟 ap 已於 step 5/6 建立
        if module_frame_id is not None:
            # fail loud:任何失敗整 transaction rollback(不要 try/except 吞掉)
            self.module_frame_template_copy_service.copy(
                module_frame_id=module_frame_id,
                ssp_id=ssp.id,
                ap_id=ap.id,
                curr_user=curr_user,
            )

→ 不加 try/except,讓 @transaction 自動 rollback。Legacy projects 沒有 module_frame_id 時跳過(if module_frame_id is not None)。

git add app/project/service/oscal_project_service.py test/test_clone_ssp_objectives_reference_documents.py
git commit -m "feat(project): copy module_frame template defaults at start; fix AO ref_docs lost on round clone"

§21

Task 18: Backward Compat 整合測試

Files:

  • Test: test/test_oscal_project_service_template_copy_integration.py
def test_start_project_with_empty_template_unchanged():
    # 既有行為驗證:模板無 defaults → SSP 全空殼 (status='unknown', desc=NULL)
    # 對照組:跑現有 start_oscal_project(不帶 copy_service) vs 帶 copy_service
    # 行為應 100% 一致

def test_step_7_5_no_duplicate_rows_after_template_copy():
    # 模板有 defaults → 啟動專案 → 確認 ssp_control_impl 行數 = ap.controls 數
    # 確認 ssp_objectives 行數 = ap.tasks 數
    # 沒有 duplicate

def test_existing_module_frame_dialog_create_unchanged():
    # POST /module-frame 跟現況一致

def test_existing_module_frame_excel_import_unchanged():
    # POST /module-frame/import/* 跟現況一致

def test_existing_ssp_update_api_unchanged():
    # PUT /ssp/.../control-implementation/... 跟現況一致

def test_module_frame_soft_delete_filters_template_defaults():
    # module_frame.is_delete=1 → list_control_defaults 不回傳該模板資料

def test_existing_ssp_reference_documents_unchanged():
    # 對應 §10A.6.4:既有 SSP 程序書 control 層 + AO 層 兩端流程不變
    # Arrange: 一個既有 SSP,已有 control 層程序書(mappings 表)+ AO 層程序書(JSONB)
    # Act: 跑舊版 PUT /ssp/<uid>/control-implementation/<ci>/reference-documents
    # Assert: response 與 db 狀態跟 feature 部署前完全相同(DB row 數、JSONB 內容)

def test_legacy_project_launch_new_round_unchanged():
    # 對應 §10A.6.6:既有專案(沒有模板 defaults)跑 launch_new_round → SSP objective clone 行為跟之前一致
    # Arrange: 啟動一個 module_frame 沒設 defaults 的專案 → close round 1 AP
    # Act: launch_new_round
    # Assert: 新 ssp_objective row 內容跟 round 1 一致;reference_documents 維持空(既有 user 沒填過)
    # 重點:非新 feature path 不受影響

def test_jedi_oscal_ssp_query_update_unchanged():
    # 對應 §10A.6.7:既有 jedi-oscal SSP query/update 流程不變
    # Arrange: 現有 SSP entity
    # Act: SspMapper.to_entity(ssp), SspMapper.to_model(entity)
    # Assert: 所有既有欄位 round-trip 不變;新加的 template_module_frame_id 為 None 時不影響
pytest test/test_oscal_project_service_template_copy_integration.py -v

Expected: ALL PASSED

git add test/test_oscal_project_service_template_copy_integration.py
git commit -m "test: backward compatibility integration for template defaults"

§22

Task 19: Golden Path + Round 2 Survival 整合測試

Files:

  • Test: test/test_oscal_project_service_template_copy_integration.py(擴增)
def test_start_project_with_populated_template_pre_fills_ssp():
    # Arrange: module_frame 有 control_default + objective_default + reference_documents
    # Act: POST /oscal/projects/start
    # Assert:
    #   - ssp.template_module_frame_id == module_frame_id
    #   - ssp_control_impl 對應 control 的 description = template's
    #   - ssp_objective 對應 statement_identifier 的 description = template's
    #   - ssp_reference_documents 有 row 對應模板程序書
    #   - ssp_objective.reference_documents JSONB 有對應內容(AO 層)

def test_round_2_launch_preserves_template_descriptions():
    # Arrange: 啟動專案後 close round 1 AP → launch_new_round
    # Act: 開新 round
    # Assert: 新 ssp_objective row 的 description / status / remarks / reference_documents 都等於 round 1 的(_clone_ssp_objectives 接手)
    # 重點驗證 reference_documents JSONB 不被 drop(§5.5 fix)
pytest test/test_oscal_project_service_template_copy_integration.py -v

Expected: ALL PASSED

git add test/test_oscal_project_service_template_copy_integration.py
git commit -m "test: template defaults golden path + round 2 program docs survival"

§23

Task 20: FE Service + 路由 + i18n

Files (frontend repo ~/Projects/Billows/Audit-Manager/compliance-manager-fe/):

  • Create: src/service/ModuleFrameTemplateService.js
  • Modify: src/config/api/api.js
  • Modify: src/config/router/index.js
  • Modify: src/config/locales/i18n/zh-tw/module-frame.json
  • Modify: src/config/locales/i18n/en/module-frame.json

api.js 新增(約照 SspService 結構):

MODULE_FRAME_CONTROL_DEFAULTS: '/module-frame/{uid}/control-defaults',
MODULE_FRAME_CONTROL_DEFAULT: '/module-frame/{uid}/control-defaults/{ci}',
MODULE_FRAME_OBJECTIVE_DEFAULTS: '/module-frame/{uid}/objective-defaults',
MODULE_FRAME_OBJECTIVE_DEFAULT: '/module-frame/{uid}/objective-defaults/{od_uid}',
MODULE_FRAME_REFERENCE_DOCS: '/module-frame/{uid}/reference-documents',
MODULE_FRAME_REFERENCE_DOC: '/module-frame/{uid}/reference-documents/{doc_uid}',
MODULE_FRAME_CONTROL_ATTACH_DOC: '/module-frame/{uid}/control-defaults/{ci}/reference-documents',
MODULE_FRAME_OBJECTIVE_ATTACH_DOC: '/module-frame/{uid}/objective-defaults/{od_uid}/reference-documents',
CATALOG_CONTROL_ASSESSMENTS: '/oscal/catalog-controls/{control_uid}/assessments',
MODULE_FRAME_TEMPLATE_EXPORT: '/module-frame/{uid}/control-defaults/export',
MODULE_FRAME_TEMPLATE_IMPORT_TEMPLATE: '/module-frame/{uid}/control-defaults/template/download',
MODULE_FRAME_TEMPLATE_IMPORT_VERIFY: '/module-frame/{uid}/control-defaults/import/verify',
MODULE_FRAME_TEMPLATE_IMPORT: '/module-frame/{uid}/control-defaults/import',

按 design.md §6.3 mirror SspService 結構,全部 method 寫齊。

router/index.js

{
    path: '/module-frame/:uid/template-edit',
    name: 'module-frame-template-edit',
    component: () => import('@/views/module_frame/ModuleFrameTemplateEditView.vue'),
    meta: { breadcrumb: [{ parent: 'module-frame', label: 'module-frame-template-edit' }] }
}

module-frame.json 中加:

  • template_edit_titletemplate_edit_breadcrumb
  • stat_controls_filledstat_aos_filled
  • label_implementation_descriptionlabel_implementation_statuslabel_responsible_rolelabel_control_originationlabel_remarks
  • placeholder_unfilledstate_filledstate_unfilled
  • btn_back_to_listbtn_batch_maintainbtn_document_poolbtn_edit_defaults
  • toast_autosavedtoast_document_uploadedtoast_document_attached
  • confirm_delete_document_with_mappings
  • accordion_ao_header

中英對齊。

git add src/service/ModuleFrameTemplateService.js src/config/api/api.js src/config/router/index.js src/config/locales/i18n/zh-tw/module-frame.json src/config/locales/i18n/en/module-frame.json
git commit -m "feat(fe): module_frame template service + route + i18n"

§24

Task 21: TemplateEditView 骨架 + Tree

Files:

  • Create: src/views/module_frame/ModuleFrameTemplateEditView.vue

複製 ProjectPlanningView.vue 的 layout:

  • Header 區
  • Split panel
  • 左 Tree(Group → Control → AO 三層 + search + expand)

砍掉:launch button / readonly banner / participants panel / canEditSsp 邏輯。

onMounted(async () => {
    const moduleFrame = await fetchModuleFrame(route.params.uid)
    const profile = moduleFrame.oscal_profile  // 含 include_groups
    // 從 profile 建 groups + controls 樹狀結構
    // 為每個 control 預先 fetch CatalogControlAssessments → 建 AO 子節點
    const defaults = await ModuleFrameTemplateService.listControlDefaults(uid)
    // 建立 controlIdentifierToHasDefault map
})

✅ 已設定 default / ⬜ 未設定。Lazy 預填的 fallback。

啟動 FE dev server npm run dev,導航到 /module-frame/<uid>/template-edit,確認 Tree 渲染。

git add src/views/module_frame/ModuleFrameTemplateEditView.vue
git commit -m "feat(fe): TemplateEditView base layout + control tree"

§25

Task 22: 控制項詳情編輯 + 自動儲存

Files:

  • Modify: src/views/module_frame/ModuleFrameTemplateEditView.vue

新增區塊(仿 ProjectPlanningView control 編輯區):

  • Textarea for implementation_description
  • Dropdown for implementation_status
  • InputText for responsible_role
  • Dropdown for control_origination
  • Textarea for remarks
import { debounce } from 'lodash-es'
const saveControlDefault = debounce(async (controlIdentifier, payload) => {
    await ModuleFrameTemplateService.upsertControlDefault(uid, controlIdentifier, payload)
    toast.add({ severity: 'success', summary: t('lang.module_frame.toast_autosaved'), life: 1500 })
}, 800)
// watch 各 ref → 觸發 saveControlDefault

編輯後等 1 秒,確認 PUT 觸發、toast 顯示、reload 後資料保留。

git add src/views/module_frame/ModuleFrameTemplateEditView.vue
git commit -m "feat(fe): control default editor with debounce auto-save"

§26

Task 23: AO Accordion 編輯

Files:

  • Modify: src/views/module_frame/ModuleFrameTemplateEditView.vue

在 control 詳情下方加 PrimeVue Accordion,每個 AO header 顯示 ccai.name + 狀態 icon。

跟控制項層編輯區相同 schema(description / status / remarks)。

呼叫 ModuleFrameTemplateService.upsertObjectiveDefault(uid, payload),body 含 control_identifier + statement_identifier + 編輯欄位

展開 AO、編輯、確認 PUT、reload 保留。

git add src/views/module_frame/ModuleFrameTemplateEditView.vue
git commit -m "feat(fe): AO accordion editor with debounce auto-save"

§27

Task 24: 程序書區塊(reuse Planning 元件)

Files:

  • Modify: src/views/module_frame/ModuleFrameTemplateEditView.vue
import ReferenceDocumentList from '@/components/grc/ReferenceDocumentList.vue'
import DocumentPoolPanel from '@/components/grc/DocumentPoolPanel.vue'
import DocumentLinkDialog from '@/components/grc/DocumentLinkDialog.vue'

塞到控制項編輯區下方。ReferenceDocumentList 接收 controlReferenceDocuments[ctrl.id] props。Service 改用 ModuleFrameTemplateService 的 attach/detach method。

每個 AO 編輯區內也加 ReferenceDocumentList

Header 加「程序書池管理」按鈕,按了開 DocumentPoolPanel Dialog。底層 service 改打 module-frame endpoint。

上傳檔案、掛到控制項、掛到 AO、確認跨控制項可重用同一檔案。

git add src/views/module_frame/ModuleFrameTemplateEditView.vue
git commit -m "feat(fe): reference document blocks + pool dialog (reuse Planning components)"

§28

Task 25: Stats Bar + Header

Files:

  • Modify: src/views/module_frame/ModuleFrameTemplateEditView.vue

仿 ProjectPlanning 的 mini-stat:

  • 「已設定 X / 總控制項 Y」(進度條)
  • 「已設定 AO X / 總 AO Y」
const filledControlsCount = computed(() => Object.keys(controlDefaultsMap.value).length)
const totalControls = computed(() => allControlsFlattened.length)
// AO 同理
git add src/views/module_frame/ModuleFrameTemplateEditView.vue
git commit -m "feat(fe): template edit header with stats + back button"

§29

Task 26: Fork SspImportDialog

Files:

  • Create: src/components/grc/ModuleFrameTemplateImportDialog.vue
cp src/components/grc/SspImportDialog.vue src/components/grc/ModuleFrameTemplateImportDialog.vue

把所有呼叫 SspService.import* 改成 ModuleFrameTemplateService.import*

Header 加「批次維護」menu,含:

  • 下載 Excel 範本
  • 匯出當前
  • 上傳 Excel(開 Dialog)

下載範本 → 填寫 → 上傳 → 確認 defaults 被批次寫入。

git add src/components/grc/ModuleFrameTemplateImportDialog.vue src/views/module_frame/ModuleFrameTemplateEditView.vue
git commit -m "feat(fe): batch import dialog (forked from SspImportDialog)"

§30

Task 27: ModuleFrame 列表入口 + 跳轉

Files:

  • Modify: src/views/module_frame/ModuleFrame.vue

每筆模板加「編輯預設值」按鈕:

<Button label="編輯預設值" icon="pi pi-pencil"
        @click="$router.push({ name: 'module-frame-template-edit', params: { uid: row.uid } })" />

addFrameFromOscal 成功後加:

router.push({ name: 'module-frame-template-edit', params: { uid: newModuleFrame.uid } })

建立新模板 → 應自動跳到編輯頁。
列表既有模板 → 點按鈕 → 跳到編輯頁。

git add src/views/module_frame/ModuleFrame.vue
git commit -m "feat(fe): module_frame list entry + create-redirect to template editor"

§31

Task 28: Manual E2E 驗證 + 文件交付

Files:

  • Create: docs/changelog/2026-04-25-module-frame-template-defaults.md

對照 design.md §10A.6 全部 9 項:

  1. [ ] 沒設 defaults 的模板啟動專案 → SSP 全 NULL(與目前一致)
  2. [ ] 既有 module_frame 列表 + Dialog 編輯 → 行為不變
  3. [ ] 既有 SSP 編輯(ProjectPlanning)→ 完全不變
  4. [ ] 既有 SSP 程序書上傳/掛接 → 完全不變
  5. [ ] 既有 module_frame Excel import → 完全不變
  6. [ ] launch_new_round Round 2 啟動 → SSP objective 跨 round clone 行為不變
  7. [ ] jedi-oscal 既有 SSP query / update 流程 → 完全不變
  8. [ ] Round 1 user 自填 AO 程序書 → launch_new_round → Round 2 仍保有程序書
  9. [ ] Step 7.5 + _copy_template_to_ssp 共存:行數無 duplicate

新功能 golden path: 10. [ ] 建立模板 → 編輯預設值(control + AO + 程序書)→ 啟動專案 → 進 ProjectPlanning 看到預填內容 11. [ ] 批次匯出 → 編輯 Excel → 批次匯入 → 確認資料寫入 12. [ ] 程序書跨控制項重用

按 CLAUDE.md 格式:

# 2026-04-25 — Module Frame Template Defaults(合規資源庫控制項預設值)

## 需求說明

把「現況說明 + 程序書」抽成 module_frame 層的模板預設值;啟動專案時自動帶入新建的 SSP,大幅減少每個專案的填寫工作量。

設計文件:`docs/features/FR-018-2604-module-frame-template-defaults/design.md`
實作計畫:`docs/features/FR-018-2604-module-frame-template-defaults/implementation-plan.md`

## 變更範圍

### Backend
| 檔案 | 動作 |
|------|------|
| scripts/sql/2026-04-25-module-frame-template-defaults.sql | 新增 — 4 表 + ALTER ssp + GRANT |
| domain/module_frame/entity/, repository/, service/ | 新增 — 4 sets entities + repos + domain services |
| infra/module_frame/models/, mapper/, repository/ | 新增 — 4 ORM models + mappers + impls |
| app/module_frame/dto/, service/ | 新增 — DTOs + 4 app services + template_copy + import |
| api/module_frame/serializers/, routes/ | 新增 — 4 sets serializers + routes |
| api/oscal/routes/catalog_control_assessment_route.py | 新增/驗證 |
| common/code/module_frame_error_code.py | 新增 |
| di_containers/module_frame/, project/ | 修改 — wire 新 services |
| app/project/service/oscal_project_service.py | 修改 — start_oscal_project 加 _copy_template_to_ssp 呼叫 + _clone_ssp_objectives 補 reference_documents copy fix |

### jedi-oscal
oscal.system_security_plans 加 template_module_frame_id 欄位;jedi-oscal minor bump 並發版

### Frontend
| 檔案 | 動作 |
|------|------|
| src/views/module_frame/ModuleFrameTemplateEditView.vue | 新增 |
| src/components/grc/ModuleFrameTemplateImportDialog.vue | 新增(fork SspImportDialog) |
| src/service/ModuleFrameTemplateService.js | 新增 |
| src/views/module_frame/ModuleFrame.vue | 修改 — 加按鈕 + 建立後跳轉 |
| src/config/api/api.js / router/index.js | 修改 — endpoints + route |
| src/config/locales/i18n/zh-tw/module-frame.json + en/ | 修改 — 新 key |

## API 變更

新增約 18 個 endpoints(control defaults / objective defaults / reference documents / mappings / catalog AO / batch import)。`POST /oscal/projects/start` request/response 不變,內部多一步對拷。

## 行為差異

| 情境 | 之前 | 現在 |
|------|------|------|
| 模板有 defaults 啟動專案 | SSP 全空殼,user 在 Planning 重填 | SSP 預填模板內容 |
| Round 2 launch 程序書 | AO 程序書消失(既有 bug) | AO 程序書保留 |
| 既有模板 / 既有專案 || 行為 100% 不變 |

## 不在本次 scope

詳見 design.md §9:SSP-per-round / SSP 版本控制 / AR snapshot / Re-sync from template / i18n trans table 等列入 Phase 2 backlog
git add docs/changelog/2026-04-25-module-frame-template-defaults.md
git commit -m "docs(changelog): module_frame template defaults"

§32

完成 / 後續

實作完成後:

  1. 確認 docs/features/FR-018-2604-module-frame-template-defaults/design.md 仍與實作一致;如有偏差更新 design doc
  2. 不執行 git push 或 PR creation(按 CLAUDE.md 規範)
  3. 等使用者明確指示後才執行 jedi-oscal 進版發佈
§33

風險提示(給實作者)

  1. Task 9 (Template Copy Service) 是整個 feature 的關鍵點 — UPDATE in place 邏輯一定要對齊 Step 7.5 的 statement_identifier convention(COALESCE(task_code, title, id::text)),否則會撞 unique constraint
  2. Task 17 中 _clone_ssp_objectives 那行 fix — 純 additive 修補既有 bug,但若漏掉,本 feature 的程序書承諾會在 round 2 破功
  3. 整合測試(Task 18-19)必須真實跑過 — 對拷邏輯複雜,unit test 不夠,要實際啟動 project 端到端驗證
  4. jedi-oscal 進版 — Task 0 完成但自動發 Nexus;等使用者指示
  5. FE Task 21-25 高度 reuse Planning 元件 — 不要從零重寫;複製結構過來再砍多餘部分