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
SQL migration:
scripts/sql/2026-04-25-module-frame-template-defaults.sqlDomain layer (4 entity sets):
domain/module_frame/entity/module_frame_control_default_entity.py + _query_entity.pydomain/module_frame/entity/module_frame_control_objective_default_entity.py + _query_entity.pydomain/module_frame/entity/module_frame_reference_document_entity.py + _query_entity.pydomain/module_frame/entity/module_frame_reference_document_mapping_entity.py + _query_entity.pydomain/module_frame/repository/module_frame_control_default_repo.py (interface)domain/module_frame/repository/module_frame_control_objective_default_repo.pydomain/module_frame/repository/module_frame_reference_document_repo.pydomain/module_frame/repository/module_frame_reference_document_mapping_repo.pydomain/module_frame/service/module_frame_control_default_domain_service.pydomain/module_frame/service/module_frame_control_objective_default_domain_service.pydomain/module_frame/service/module_frame_reference_document_domain_service.pydomain/module_frame/service/module_frame_reference_document_mapping_domain_service.pyInfra layer:
infra/module_frame/models/module_frame_control_default.pyinfra/module_frame/models/module_frame_control_objective_default.pyinfra/module_frame/models/module_frame_reference_document.pyinfra/module_frame/models/module_frame_reference_document_mapping.pyinfra/module_frame/mapper/module_frame_control_default_mapper.pyinfra/module_frame/mapper/module_frame_control_objective_default_mapper.pyinfra/module_frame/mapper/module_frame_reference_document_mapper.pyinfra/module_frame/mapper/module_frame_reference_document_mapping_mapper.pyinfra/module_frame/repository/module_frame_control_default_repo_impl.pyinfra/module_frame/repository/module_frame_control_objective_default_repo_impl.pyinfra/module_frame/repository/module_frame_reference_document_repo_impl.pyinfra/module_frame/repository/module_frame_reference_document_mapping_repo_impl.pyApp layer:
app/module_frame/dto/module_frame_control_default_dto.pyapp/module_frame/dto/module_frame_control_objective_default_dto.pyapp/module_frame/dto/module_frame_reference_document_dto.pyapp/module_frame/service/module_frame_control_default_service.pyapp/module_frame/service/module_frame_control_objective_default_service.pyapp/module_frame/service/module_frame_reference_document_service.pyapp/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.pyapi/module_frame/serializers/module_frame_control_objective_default.pyapi/module_frame/serializers/module_frame_reference_document.pyapi/module_frame/routes/module_frame_control_default_route.pyapi/module_frame/routes/module_frame_control_objective_default_route.pyapi/module_frame/routes/module_frame_reference_document_route.pyapi/module_frame/routes/module_frame_reference_document_mapping_route.pyapi/module_frame/routes/module_frame_template_import_route.pyapi/oscal/routes/catalog_control_assessment_route.py(如已有則跳過)Common / Containers:
common/code/module_frame_error_code.py(新檔)Tests:
test/test_module_frame_control_default_service.pytest/test_module_frame_control_objective_default_service.pytest/test_module_frame_reference_document_service.pytest/test_module_frame_template_copy_service.py(最重要)test/test_module_frame_template_import_service.pytest/test_oscal_project_service_template_copy_integration.pytest/test_clone_ssp_objectives_reference_documents.pyinfra/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 + reposdi_containers/project/...:inject module_frame_template_copy_service 進 OscalProjectServicejedi_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:加新欄位compliance-manager-fe/src/views/module_frame/ModuleFrameTemplateEditView.vuecompliance-manager-fe/src/components/grc/ModuleFrameTemplateImportDialog.vue(fork SspImportDialog.vue)compliance-manager-fe/src/service/ModuleFrameTemplateService.jscompliance-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 routecompliance-manager-fe/src/config/locales/i18n/zh-tw/module-frame.json:新 keycompliance-manager-fe/src/config/locales/i18n/en/module-frame.json:新 keyPhase 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)
Files (jedi-oscal package, 路徑相對於 ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/):
jedi_oscal/infra/model/ssp/ssp.pyjedi_oscal/infra/mapper/ssp/ssp_mapper.pyjedi_oscal/domain/entity/ssp/ssp_entity.pypyproject.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.py 在 to_entity 加 template_module_frame_id=getattr(model, 'template_module_frame_id', None),to_model 加 m.template_module_frame_id = getattr(entity, 'template_module_frame_id', None)
ssp_entity.py OscalSystemSecurityPlanEntity 加 template_module_frame_id: Optional[int] = None
pyproject.toml 把版本號 minor bump(如 0.0.8 → 0.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 == 42cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal
pytest tests/test_ssp_template_module_frame_id.py -vExpected: 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"Files:
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.sqlExpected: 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)"Files:
infra/module_frame/models/module_frame_control_default.pyinfra/module_frame/models/module_frame_control_objective_default.pyinfra/module_frame/models/module_frame_reference_document.pyinfra/module_frame/models/module_frame_reference_document_mapping.py讀 infra/module_frame/models/module_frame.py 跟 infra/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.uid 用 String(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"Files:
domain/module_frame/entity/module_frame_control_default_entity.py + _query_entity.pydomain/module_frame/entity/module_frame_control_objective_default_entity.py + _query_entity.pydomain/module_frame/entity/module_frame_reference_document_entity.py + _query_entity.pydomain/module_frame/entity/module_frame_reference_document_mapping_entity.py + _query_entity.pydomain/module_frame/repository/module_frame_control_default_repo.pydomain/module_frame/repository/module_frame_control_objective_default_repo.pydomain/module_frame/repository/module_frame_reference_document_repo.pydomain/module_frame/repository/module_frame_reference_document_mapping_repo.py讀 domain/module_frame/entity/module_frame_entity.py 跟 module_frame_query_entity.py,跟著模式建立。
*_entity.py(dataclass,full record)+ *_query_entity.py(dataclass,optional fields for filtering)。共 8 個檔案。
抽象 method 至少包含:
get_one(query_entity) → entity 或 Noneget_all(query_entity) → list[entity]add(entity) → entityupdate(entity) → entitydelete_by_id(id) → boolbulk_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"Files:
infra/module_frame/mapper/module_frame_control_default_mapper.py + 3 othersinfra/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"Files:
domain/module_frame/service/module_frame_control_default_domain_service.pydomain/module_frame/service/module_frame_control_objective_default_domain_service.pydomain/module_frame/service/module_frame_reference_document_domain_service.pydomain/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"Files:
common/code/module_frame_error_code.pyapp/module_frame/dto/module_frame_control_default_dto.pyapp/module_frame/service/module_frame_control_default_service.pytest/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 -vExpected: FAIL(service 還沒寫)
module_frame_control_default_service.py — 含 @transaction 裝飾器、權限檢查(manager 角色)、profile 驗證、upsert 邏輯。
pytest test/test_module_frame_control_default_service.py -vExpected: 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"Files:
app/module_frame/dto/module_frame_control_objective_default_dto.pyapp/module_frame/service/module_frame_control_objective_default_service.pytest/test_module_frame_control_objective_default_service.pydef 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 驗證:
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"Files:
app/module_frame/dto/module_frame_reference_document_dto.pyapp/module_frame/service/module_frame_reference_document_service.pytest/test_module_frame_reference_document_service.pydef 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_documentsattach_to_control_default(...) / attach_to_objective_default(...) — INSERT mappingsdetach(...) — DELETE 單筆 mapping(不刪 doc)delete_doc(doc_uid) — DELETE doc(CASCADE mappings)list_pool(module_frame_uid) — 查池 + 每筆 mapping countgit 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"Files:
app/module_frame/service/module_frame_template_copy_service.pytest/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 -vExpected: 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 -vExpected: 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)"Files:
app/module_frame/service/module_frame_template_import_service.pytest/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"Files:
api/module_frame/serializers/module_frame_control_default.pyapi/module_frame/routes/module_frame_control_default_route.pytest/test_api_module_frame_control_default_route.pydef 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)。
ListResource — GET /module-frame/<uid>/control-defaultsDetailResource — GET /module-frame/<uid>/control-defaults/<control_identifier>UpsertResource — PUT /module-frame/<uid>/control-defaults/<control_identifier>DeleteResource — DELETE /module-frame/<uid>/control-defaults/<control_identifier>每個 method 註冊 webargs schemas + 呼叫對應 app service。
pytest test/test_api_module_frame_control_default_route.py -vExpected: 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"Files:
api/module_frame/serializers/module_frame_control_objective_default.pyapi/module_frame/routes/module_frame_control_objective_default_route.pyGET /module-frame/<uid>/objective-defaultsGET /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"Files:
api/module_frame/serializers/module_frame_reference_document.pyapi/module_frame/routes/module_frame_reference_document_route.pyapi/module_frame/routes/module_frame_reference_document_mapping_route.pyGET /module-frame/<uid>/reference-documentsPOST /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-documentsDELETE /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"Files:
api/module_frame/routes/module_frame_template_import_route.pyGET /module-frame/<uid>/control-defaults/exportGET /module-frame/<uid>/control-defaults/template/downloadPOST /module-frame/<uid>/control-defaults/import/templatePOST /module-frame/<uid>/control-defaults/import/verifyPOST /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"Files:
api/oscal/routes/catalog_control_assessment_route.py(如已有則跳過)grep -rn "catalog.control.assessment" api/oscal/ 2>/dev/nullGET /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"Files:
di_containers/module_frame/module_frame_containers.pyconfig/app_modules.py(如新 service 需註冊)di_containers/project/...(給 OscalProjectService inject template_copy_service)在 module_frame_containers.py 加:
module_frame_control_default_repo 等)修改 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/ | headExpected: Swagger 載入成功,沒有 DI 錯誤
git add di_containers/
git commit -m "feat(di): wire module_frame template default services"Files:
app/project/service/oscal_project_service.pytest/test_clone_ssp_objectives_reference_documents.pydef 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 -vExpected: FAIL(因為原 code 沒 copy)
oscal_project_service.py line 623 是 remarks=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 -vExpected: PASSED
變數 module_frame_id 已存在於 start_oscal_project line 218(透過 mf.id if mf else None 取得)。ssp 跟 ap 物件也已在 scope 內。
在 start_oscal_project 的 Step 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"Files:
test/test_oscal_project_service_template_copy_integration.pydef 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 -vExpected: ALL PASSED
git add test/test_oscal_project_service_template_copy_integration.py
git commit -m "test: backward compatibility integration for template defaults"Files:
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 -vExpected: 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"Files (frontend repo ~/Projects/Billows/Audit-Manager/compliance-manager-fe/):
src/service/ModuleFrameTemplateService.jssrc/config/api/api.jssrc/config/router/index.jssrc/config/locales/i18n/zh-tw/module-frame.jsonsrc/config/locales/i18n/en/module-frame.jsonapi.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_title、template_edit_breadcrumbstat_controls_filled、stat_aos_filledlabel_implementation_description、label_implementation_status、label_responsible_role、label_control_origination、label_remarksplaceholder_unfilled、state_filled、state_unfilledbtn_back_to_list、btn_batch_maintain、btn_document_pool、btn_edit_defaultstoast_autosaved、toast_document_uploaded、toast_document_attachedconfirm_delete_document_with_mappingsaccordion_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"Files:
src/views/module_frame/ModuleFrameTemplateEditView.vue複製 ProjectPlanningView.vue 的 layout:
砍掉: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"Files:
src/views/module_frame/ModuleFrameTemplateEditView.vue新增區塊(仿 ProjectPlanningView control 編輯區):
Textarea for implementation_descriptionDropdown for implementation_statusInputText for responsible_roleDropdown for control_originationTextarea for remarksimport { 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"Files:
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"Files:
src/views/module_frame/ModuleFrameTemplateEditView.vueimport 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)"Files:
src/views/module_frame/ModuleFrameTemplateEditView.vue仿 ProjectPlanning 的 mini-stat:
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"Files:
src/components/grc/ModuleFrameTemplateImportDialog.vuecp src/components/grc/SspImportDialog.vue src/components/grc/ModuleFrameTemplateImportDialog.vue把所有呼叫 SspService.import* 改成 ModuleFrameTemplateService.import*。
Header 加「批次維護」menu,含:
下載範本 → 填寫 → 上傳 → 確認 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)"Files:
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"Files:
docs/changelog/2026-04-25-module-frame-template-defaults.md對照 design.md §10A.6 全部 9 項:
_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 backloggit add docs/changelog/2026-04-25-module-frame-template-defaults.md
git commit -m "docs(changelog): module_frame template defaults"實作完成後:
docs/features/FR-018-2604-module-frame-template-defaults/design.md 仍與實作一致;如有偏差更新 design docCOALESCE(task_code, title, id::text)),否則會撞 unique constraint_clone_ssp_objectives 那行 fix — 純 additive 修補既有 bug,但若漏掉,本 feature 的程序書承諾會在 round 2 破功