Spec 3「AP 套用流程」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: 讓 user 建專案 / 建後續輪 AP 時挑選流程範本,AP 建立同時 snapshot clone 範本 BPMN 到該 AP 專屬 workflow_execution,達成範本生命週期與 AP 完全解耦。

Architecture: 拔除 Spec 2 過渡期的 hardcode default 邏輯,把 flow_template_uid 改為必填參數沿 start_oscal_project + launch_new_round 兩條 entry 傳入;reuse Spec 2 已落地的 WorkflowTemplateSnapshotService.clone_master_as_snapshot 做 per-AP immutable snapshot;補 launch_new_round 缺漏的 main workflow binding(Spec 2 漏蓋);AP list + detail API 新增 flow_template enrichment 讓 FE default 帶值與顯示。

Tech Stack:

  • BE: Python 3 / Flask / Flask-RESTful / SQLAlchemy / marshmallow / dependency-injector / pytest
  • FE: Vue 3 (Composition API) / Vite / Pinia / PrimeVue 3.53 / vee-validate / vue-i18n
  • DB: PostgreSQL 14 (compliance / public / oscal schemas with RLS)
  • 內部套件: jedi-flow-engine (path mode during dev, see CLAUDE.md "jedi-* 套件" 段)

Spec 來源: docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md v2.1


§1

開工前必讀

  1. docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md — Spec 3 設計 v2.1(必讀,本 plan 對應其 M0–M8)
  2. docs/features/FR-026-2605-project-flow-engine-integrate/02-stage-integration/design.md — Spec 2 已 ship;§十 reconciliation 是 Spec 3 銜接點
  3. docs/features/FR-026-2605-project-flow-engine-integrate/01-flow-template-management/design.md — Spec 1 已 ship;範本列表 API + Preview Dialog 是依賴
  4. CLAUDE.md — 開發規範(DDD / @transaction / Error code / API patterns)
  5. docs/claude/jedi-packages.md — jedi-flow-engine API 參考
  6. docs/claude/frontend-overview.md — FE 設計 cheatsheet(design tokens / PrimeVue quirks / Audit Lifecycle UI)
§2

環境前置

  • BE: feature/project-flow-engine-integrate branch(HEAD = 5acceb0 之後)
  • BE: pyproject.toml 已切 jedi-flow-engine path mode(working tree modified,不 commit)
  • BE: python main_socketio.py 跑得起來(port 8000);日誌 log/app.log
  • BE: psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev(密碼 jedi@123!;migration 一律用 cmmgr 不用 cm_app
  • FE: compliance-manager-fe 同 branch;npm run dev 起 5173
  • 測試帳號: blsadmin / Billows@123!(manual);blsit / Billows@123!(pytest)

§3

M0 — Pre-flight Verification

Task M0.1: Grep verification — 確認 default lookup 無其他 caller

Files: 純檢查、無修改

cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
grep -rn "get_default_main_workflow_template" --include="*.py"

Expected: 僅 2 個 hit(定義 domain/flow_engine/service/flow_template_domain_service.py:35 + caller app/project/service/oscal_project_service.py:510

grep -rn "get_builtin_by_name" --include="*.py"

Expected: 僅 3 個 hit(interface / impl / domain service 內部呼叫)

grep -rn "DEFAULT_MAIN_WORKFLOW_TEMPLATE_NAME" --include="*.py"

Expected: 僅 1 個 hit(定義處)

grep -rn "GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND" --include="*.py"

Expected: 含 common/code/grc_error_code.py:198 定義 + app/project/service/oscal_project_service.py:508+512 兩處 raise。若有其他 hit,記下來 M4 一併處理。

grep -rn "_bind_main_workflow_to_ap" --include="*.py"

Expected: 僅 2 個 hit — 定義 app/project/service/oscal_project_service.py:492 + 呼叫 :487。M4 會新增第二個 caller(launch_new_round)。

poetry show jedi-flow-engine | grep -i location

Expected: 輸出 ~/Projects/Jedicogy/module/jedi-python-package/jedi-flow-engine 而非 .venv/lib/...

ls ~/Projects/Billows/Audit-Manager/compliance-manager-fe/docs/changelog/ 2>/dev/null | head -3

Expected: 若資料夾存在 → FE 有獨立 changelog 慣例(M5/M6 各自寫一筆);若不存在 → FE 改動的 changelog 寫在 BE repo docs/changelog/ 標記 modules: [frontend]

grep -n "profile_uid\|profileUid\|compliance.*資源" ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/project/ProjectCreateView.vue ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/components/grc/ProjectBasicInfoForm.vue 2>/dev/null

判斷邏輯:若 ProjectBasicInfoForm 已含 profile 相關 dropdown,flow_template dropdown 加在這裡保持「合規資源 + 流程範本」一組;否則加在 ProjectCreateView 該 step 的 inline form。M5 直接套用此結論。

輸出: 寫一段 verification 結果到 conversation(含 FE 慣例 + dropdown 落點決定),沒問題就進 M1。


§4

M1 — BE Error Code 整理

Task M1.1: 刪除 GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND + fix GRC_AR_DATA_MISSING 編號

Files:

  • Modify: common/code/grc_error_code.py:198, 201
sed -n '195,205p' common/code/grc_error_code.py

Expected: 看到 line 198 GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND = ("找不到預設主流程範本(seed 缺失)", "GRC_412022") 跟 line 201 GRC_AR_DATA_MISSING = ("找不到稽核回合資料", "GRC_500001")

用 Edit tool 刪掉 common/code/grc_error_code.py:198 整行(含換行):

# 刪除這行
GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND   = ("找不到預設主流程範本(seed 缺失)", "GRC_412022")

common/code/grc_error_code.py:201

# Before
GRC_AR_DATA_MISSING                = ("找不到稽核回合資料",           "GRC_500001")
# After
GRC_AR_DATA_MISSING                = ("找不到稽核回合資料",           "GRC_412024")

理由:HTTP 412 PreconditionFailed 不該配 500xxx 編號(違反 CLAUDE.md error code 命名規則)。

grep -rn "GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND" --include="*.py"

Expected: 應該有 app/project/service/oscal_project_service.py:508, 512 兩處還在 raise — 這兩處留到 M4 一併 fix(M4 改 _bind_main_workflow_to_ap 時整段 helper 重寫,順手換掉 raise 對象)。

python -c "from common.code.grc_error_code import GrcErrorCode; print(GrcErrorCode.GRC_AR_DATA_MISSING)"

Expected: 印出 ("找不到稽核回合資料", "GRC_412024") 不會 ImportError

Create docs/changelog/2026-05-13-tweak-grc-error-code-cleanup.md:

---
type: tweak
modules: [common.code.grc_error_code]
issue: docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md (§9.7)
---

# GRC error code 清理:刪 412022 + fix 500001 編號

## 變更
- 刪除 `GRC_FLOW_MAIN_TEMPLATE_NOT_FOUND` (412022)
- `GRC_AR_DATA_MISSING` 編號從 500001 改 412024

## 原因
- 412022: Spec 3 拔除 hardcode default lookup 後不再需要
- 500001: HTTP 412 配 500 編號違反 CLAUDE.md error code 命名規則
git add common/code/grc_error_code.py docs/changelog/2026-05-13-tweak-grc-error-code-cleanup.md
git commit -m "tweak(spec3 M1): GRC error code 清理 — 刪 412022 + fix 500001→412024"

§5

M2 — BE AP List + Detail Enrichment

Task M2.1: 設計 flow_template enrichment 共用 helper

Files:

  • Create: app/grc/service/_flow_template_enricher.py
grep -n "get_assessment_plans_menu\|get_assessment_plan_detail" app/grc/service/*.py

Expected: 在 app/grc/service/project_service.py 找到 method 定義

Create test/test_flow_template_enricher.py:

"""Spec 3 M2 — FlowTemplateEnricher unit tests."""
import pytest
from unittest.mock import MagicMock

from app.grc.service._flow_template_enricher import FlowTemplateEnricher


class TestFlowTemplateEnricher:
    def test_enrich_returns_full_payload_when_all_layers_exist(self):
        ext_service = MagicMock()
        engine_service = MagicMock()
        master_service = MagicMock()

        ext_service.get_by_assessment_plan_ids.return_value = {
            1: MagicMock(flow_template_snapshot_uid="snap-uid-1")
        }
        engine_service.get_many_by_uids.return_value = {
            "snap-uid-1": MagicMock(source_template_uid="master-uid-1")
        }
        master_service.get_many_by_uids.return_value = {
            "master-uid-1": MagicMock(name="完整稽核流程", is_active=True)
        }

        enricher = FlowTemplateEnricher(ext_service, engine_service, master_service)
        result = enricher.enrich_many(ap_ids=[1])

        assert result[1] == {
            "snapshot_uid": "snap-uid-1",
            "master_uid": "master-uid-1",
            "master_name": "完整稽核流程",
            "is_master_active": True,
        }

    def test_enrich_returns_none_when_ext_missing(self):
        ext_service = MagicMock()
        engine_service = MagicMock()
        master_service = MagicMock()
        ext_service.get_by_assessment_plan_ids.return_value = {}

        enricher = FlowTemplateEnricher(ext_service, engine_service, master_service)
        result = enricher.enrich_many(ap_ids=[1])

        assert result[1] is None

    def test_enrich_marks_master_inactive_when_soft_deleted(self):
        ext_service = MagicMock()
        engine_service = MagicMock()
        master_service = MagicMock()

        ext_service.get_by_assessment_plan_ids.return_value = {
            1: MagicMock(flow_template_snapshot_uid="snap-uid-1")
        }
        engine_service.get_many_by_uids.return_value = {
            "snap-uid-1": MagicMock(source_template_uid="master-uid-1")
        }
        master_service.get_many_by_uids.return_value = {
            "master-uid-1": MagicMock(name="Deleted", is_active=False)
        }

        enricher = FlowTemplateEnricher(ext_service, engine_service, master_service)
        result = enricher.enrich_many(ap_ids=[1])

        assert result[1]["is_master_active"] is False

    def test_enrich_uses_batch_queries(self):
        """避免 N+1:N 個 AP 應該只觸發 3 次 query(ext / engine / master)"""
        ext_service = MagicMock()
        engine_service = MagicMock()
        master_service = MagicMock()
        ext_service.get_by_assessment_plan_ids.return_value = {}

        enricher = FlowTemplateEnricher(ext_service, engine_service, master_service)
        enricher.enrich_many(ap_ids=[1, 2, 3, 4, 5])

        # 即使 5 個 AP 也只該 call 一次 ext_service.get_by_assessment_plan_ids
        ext_service.get_by_assessment_plan_ids.assert_called_once_with([1, 2, 3, 4, 5])
pytest test/test_flow_template_enricher.py -v

Expected: FAIL with ImportError: cannot import name 'FlowTemplateEnricher'

Create app/grc/service/_flow_template_enricher.py:

"""Spec 3 M2 — AP `flow_template` enrichment helper.

DDD note: 純 app service 層 helper,不直接碰 ORM。透過注入的 domain service 取資料。
"""
from typing import Dict, List, Optional


class FlowTemplateEnricher:
    """把 N 個 AP id batch enrich 成 `flow_template` payload dict。

    Caller 已在 `@transaction` scope 內。
    """

    def __init__(
        self,
        ap_extension_domain_service,
        workflow_template_service,
        flow_template_domain_service,
    ):
        self._ext = ap_extension_domain_service
        self._engine = workflow_template_service
        self._master = flow_template_domain_service

    def enrich_many(self, ap_ids: List[int]) -> Dict[int, Optional[dict]]:
        """Return {ap_id: flow_template_payload | None}.

        Lenient:任一層缺失就回 None / `is_master_active=False`,不 raise。
        """
        if not ap_ids:
            return {}

        # 一段 query:取所有 ext
        exts_by_ap_id = self._ext.get_by_assessment_plan_ids(ap_ids)
        if not exts_by_ap_id:
            return {ap_id: None for ap_id in ap_ids}

        # 二段 query:取所有 snapshot(engine 表)
        snapshot_uids = [
            ext.flow_template_snapshot_uid for ext in exts_by_ap_id.values()
            if ext and ext.flow_template_snapshot_uid
        ]
        snapshots_by_uid = self._engine.get_many_by_uids(snapshot_uids)

        # 三段 query:取所有 master
        master_uids = [
            snap.source_template_uid for snap in snapshots_by_uid.values()
            if snap and snap.source_template_uid
        ]
        masters_by_uid = self._master.get_many_by_uids(master_uids)

        # 組裝
        result = {}
        for ap_id in ap_ids:
            ext = exts_by_ap_id.get(ap_id)
            if not ext or not ext.flow_template_snapshot_uid:
                result[ap_id] = None
                continue
            snap = snapshots_by_uid.get(ext.flow_template_snapshot_uid)
            if not snap or not snap.source_template_uid:
                result[ap_id] = None
                continue
            master = masters_by_uid.get(snap.source_template_uid)
            if not master:
                # snapshot 還在但 master 連 row 都沒了 — 回部分資料
                result[ap_id] = {
                    "snapshot_uid": ext.flow_template_snapshot_uid,
                    "master_uid": snap.source_template_uid,
                    "master_name": None,
                    "is_master_active": False,
                }
                continue
            result[ap_id] = {
                "snapshot_uid": ext.flow_template_snapshot_uid,
                "master_uid": snap.source_template_uid,
                "master_name": master.name,
                "is_master_active": bool(master.is_active),
            }
        return result
pytest test/test_flow_template_enricher.py -v

Expected: 4 tests PASS

git add app/grc/service/_flow_template_enricher.py test/test_flow_template_enricher.py
git commit -m "feat(spec3 M2): FlowTemplateEnricher helper for AP flow_template enrichment"

Task M2.2: 補 domain service / repo batch query method

Files:

  • Modify: domain/flow_engine/service/flow_template_domain_service.py
  • Modify: domain/flow_engine/repository/flow_template_repo.py
  • Modify: infra/flow_engine/repository/flow_template_repo_impl.py
  • Modify: domain/ap_extension_* (path TBD by grep)
grep -rn "AssessmentPlanExtensionDomainService\|assessment_plan_extensions" --include="*.py" -l | head -5

注意每個 service 都應有對應 test/test_<service>.py;找不到就先 create file。完整 test bodies:

test/test_flow_template_domain_service.py

import pytest
from unittest.mock import MagicMock
from domain.flow_engine.service.flow_template_domain_service import FlowTemplateDomainService


def test_get_many_by_uids_returns_dict_keyed_by_uid():
    repo = MagicMock()
    repo.get_many_by_uids.return_value = {
        "uid-1": MagicMock(uid="uid-1", name="A"),
        "uid-2": MagicMock(uid="uid-2", name="B"),
    }
    service = FlowTemplateDomainService(repo)
    result = service.get_many_by_uids(["uid-1", "uid-2"])
    assert list(result.keys()) == ["uid-1", "uid-2"]
    assert result["uid-1"].name == "A"

def test_get_many_by_uids_empty_list_returns_empty_dict():
    repo = MagicMock()
    service = FlowTemplateDomainService(repo)
    assert service.get_many_by_uids([]) == {}
    repo.get_many_by_uids.assert_not_called()  # 短路:空 list 不打 repo

def test_get_many_by_uids_missing_uids_omitted():
    repo = MagicMock()
    repo.get_many_by_uids.return_value = {"uid-1": MagicMock()}
    service = FlowTemplateDomainService(repo)
    result = service.get_many_by_uids(["uid-1", "uid-2"])  # uid-2 不存在
    assert "uid-2" not in result

test/test_ap_extension_domain_service.py

def test_get_by_assessment_plan_ids_returns_dict_keyed_by_ap_id():
    repo = MagicMock()
    repo.get_by_assessment_plan_ids.return_value = {
        1: MagicMock(assessment_plan_id=1, flow_template_snapshot_uid="snap-1"),
        2: MagicMock(assessment_plan_id=2, flow_template_snapshot_uid="snap-2"),
    }
    service = AssessmentPlanExtensionDomainService(repo)
    result = service.get_by_assessment_plan_ids([1, 2, 3])
    assert set(result.keys()) == {1, 2}
    assert result[1].flow_template_snapshot_uid == "snap-1"

def test_get_by_assessment_plan_ids_empty_list_short_circuits():
    repo = MagicMock()
    service = AssessmentPlanExtensionDomainService(repo)
    assert service.get_by_assessment_plan_ids([]) == {}
    repo.get_by_assessment_plan_ids.assert_not_called()

test/test_workflow_template_service.py(注意這是 jedi-flow-engine 套件的 service — 修改範圍跨套件):

def test_get_many_by_uids_returns_dict():
    # 套件層級加 method,測試與上述同 pattern
    ...

注意:若 WorkflowTemplateService 是 jedi-flow-engine 套件的 service,加 get_many_by_uids method 是套件 API 異動。依 CLAUDE.md「外部套件異動規範」:開工前確認改動範圍 + dev 走 path mode(pyproject 已切)、feature 完成才 bump。本 task 改動是必要的(FlowTemplateEnricher batch query 需要),算合理範圍。

pytest test/test_flow_template_domain_service.py -v
pytest test/test_ap_extension_domain_service.py -v
pytest test/test_workflow_template_service.py -v

Expected: 3 FAIL with AttributeError: ... has no attribute 'get_many_by_uids' / 'get_by_assessment_plan_ids'

在每個 repo / domain service 加:

# repo interface
def get_many_by_uids(self, uids: List[str]) -> Dict[str, EntityT]: ...

# repo impl
def get_many_by_uids(self, uids):
    if not uids:
        return {}
    rows = self.session.query(self._model).filter(self._model.uid.in_(uids)).all()
    return {str(row.uid): self._mapper.to_entity(row) for row in rows}

# domain service
def get_many_by_uids(self, uids):
    return self._repo.get_many_by_uids(uids)

AssessmentPlanExtensionDomainService.get_by_assessment_plan_ids

def get_by_assessment_plan_ids(self, ap_ids):
    if not ap_ids:
        return {}
    rows = self.session.query(...).filter(model.assessment_plan_id.in_(ap_ids)).all()
    return {row.assessment_plan_id: mapper.to_entity(row) for row in rows}

Expected: 3 PASS

git add domain/ infra/ test/
git commit -m "feat(spec3 M2): batch query methods for FlowTemplate / AP extension / WorkflowTemplate"

Task M2.3: AssessmentPlanMenuResponseSchema 加 flow_template 欄位

Files:

  • Modify: api/grc/serializers/assessment_plan.py:12-30
# api/grc/serializers/assessment_plan.py

class FlowTemplateEnrichmentSchema(Schema):
    snapshot_uid = fields.String(allow_none=True)
    master_uid = fields.String(allow_none=True)
    master_name = fields.String(allow_none=True)
    is_master_active = fields.Boolean(allow_none=True)


class AssessmentPlanMenuResponseSchema(Schema):
    uid = fields.String()
    # ...既有欄位...
    ssp_uid = fields.String(allow_none=True)
    flow_template = fields.Nested(FlowTemplateEnrichmentSchema, allow_none=True)  # 新增
git add api/grc/serializers/assessment_plan.py
git commit -m "feat(spec3 M2): add flow_template enrichment field to AssessmentPlanMenuResponseSchema"

Task M2.4: AP list / detail service 整合 enricher

Files:

  • Modify: app/grc/service/project_service.py (get_assessment_plans_menu / detail)
  • Modify: di_containers/grc/grc_container.py (wire FlowTemplateEnricher)

test/test_grc_assessment_plan_api.py:

def test_get_assessment_plans_menu_returns_flow_template_enrichment(
    app_client, blsit_headers, project_with_two_aps
):
    """AP list endpoint 必須回 flow_template enrichment(FE default 帶值依賴)。"""
    resp = app_client.get(
        f"/grc/project/{project_with_two_aps.uid}/assessment-plans/menu",
        headers=blsit_headers,
    )
    assert resp.status_code == 200
    aps = resp.json["data"]
    assert len(aps) == 2
    for ap in aps:
        assert "flow_template" in ap
        assert ap["flow_template"]["master_uid"] is not None
        assert ap["flow_template"]["snapshot_uid"] is not None
        assert ap["flow_template"]["master_name"] is not None
        assert ap["flow_template"]["is_master_active"] is True

def test_get_assessment_plan_detail_returns_flow_template_enrichment(
    app_client, blsit_headers, ap_fixture
):
    resp = app_client.get(
        f"/grc/project/{ap_fixture.project_uid}/ap/{ap_fixture.uid}",
        headers=blsit_headers,
    )
    assert resp.status_code == 200
    assert resp.json["data"]["flow_template"]["master_name"] == "完整稽核流程"

Expected: FAIL KeyError: 'flow_template'(schema 加了但 service 還沒 enrich)

class ProjectService:
    def __init__(
        self,
        # ...既有 deps...
        flow_template_enricher: FlowTemplateEnricher = None,  # 新增
    ):
        self._flow_template_enricher = flow_template_enricher

    @transaction
    def get_assessment_plans_menu(self, project_uid):
        aps = self._existing_logic(project_uid)
        ap_ids = [ap.id for ap in aps]
        enrichments = self._flow_template_enricher.enrich_many(ap_ids)
        for ap in aps:
            ap.flow_template = enrichments.get(ap.id)
        return aps

Detail endpoint 類似處理。

di_containers/grc/grc_container.py

flow_template_enricher = providers.Factory(
    FlowTemplateEnricher,
    ap_extension_domain_service=...,
    workflow_template_service=...,
    flow_template_domain_service=...,
)
project_service = providers.Factory(
    ProjectService,
    # ...
    flow_template_enricher=flow_template_enricher,
)
pytest test/test_grc_assessment_plan_api.py -v

Expected: 2 PASS

# Kill stale (memory: backend_restart_orphan_pids)
lsof -i :8000  # 確認 listener
pkill -9 -f main_socketio.py
python main_socketio.py > /dev/null 2>&1 &

curl -s -H "Authorization: Bearer <token>" http://localhost:8000/grc/project/<uid>/assessment-plans/menu | jq '.data[0].flow_template'

Expected: 印出 {snapshot_uid, master_uid, master_name, is_master_active} 完整 payload

git add app/grc/service/project_service.py di_containers/grc/grc_container.py test/test_grc_assessment_plan_api.py
git commit -m "feat(spec3 M2): AP list/detail API enrich with flow_template payload"

Task M2.5: Changelog M2

docs/changelog/2026-05-13-feat-ap-flow-template-enrichment.md:

---
type: feat
modules: [grc, flow_engine]
issue: docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md (§9.6)
---

# AP list + detail API 新增 flow_template enrichment

## 變更
- `GET /grc/project/<uid>/assessment-plans/menu``flow_template: {snapshot_uid, master_uid, master_name, is_master_active}`(每筆 AP)
- `GET /grc/project/<uid>/ap/<ap_uid>` 同上
- 行為 lenient:master 軟刪後 `is_master_active=false` 不擋 API(進行中 AP 仍可跑完)
- `FlowTemplateEnricher` 共用 helper + 三段 batch query 防 N+1

## API 差異
(before / after JSON 範例)

## 測試
- 2 integration test + 4 unit test 全 pass
git add docs/changelog/2026-05-13-feat-ap-flow-template-enrichment.md
git commit -m "docs(spec3 M2): changelog for AP flow_template enrichment"

§6

M3 — Spec 2 Carry-forward Test Gap 補測試

理由:M4 改 _bind_main_workflow_to_ap 會影響 WorkflowTemplateSnapshotService / StageAdvanceService 等。先 M3 補測試,M4 出 regression 立刻 fail。

Task M3.1: WorkflowTemplateSnapshotService unit test

Files:

  • Create: test/test_workflow_template_snapshot_service.py
"""Spec 3 M3 carry-forward — WorkflowTemplateSnapshotService unit tests."""
import pytest
from unittest.mock import MagicMock
from app.flow_engine.service.workflow_template_snapshot_service import (
    WorkflowTemplateSnapshotService,
)


class TestWorkflowTemplateSnapshotService:
    def test_clone_master_as_snapshot_returns_new_uid(self):
        engine_service = MagicMock()
        engine_service.add_workflow_template.return_value = MagicMock(uid="new-uid-1", id=99)
        service = WorkflowTemplateSnapshotService(engine_service)

        result = service.clone_master_as_snapshot(
            master_uid="master-uid-1",
            master_name="完整稽核流程",
            xml="<bpmn>...</bpmn>",
            description="desc",
            curr_user="test-user",
        )

        assert result.uid == "new-uid-1"
        engine_service.add_workflow_template.assert_called_once()
        kwargs = engine_service.add_workflow_template.call_args.kwargs
        assert kwargs["type"] == "MAIN_PROCESS"
        assert kwargs["version"] == "1.0.0"
        assert kwargs["provider"] == "snapshot"
        assert kwargs["source_template_uid"] == "master-uid-1"
        assert kwargs["xml"] == "<bpmn>...</bpmn>"

    def test_clone_two_times_returns_different_uids(self):
        engine_service = MagicMock()
        engine_service.add_workflow_template.side_effect = [
            MagicMock(uid="snap-1"),
            MagicMock(uid="snap-2"),
        ]
        service = WorkflowTemplateSnapshotService(engine_service)

        r1 = service.clone_master_as_snapshot(
            master_uid="m", master_name="n", xml="<x/>", curr_user="u"
        )
        r2 = service.clone_master_as_snapshot(
            master_uid="m", master_name="n", xml="<x/>", curr_user="u"
        )

        assert r1.uid != r2.uid

    def test_clone_uses_master_name_as_prefix(self):
        engine_service = MagicMock()
        engine_service.add_workflow_template.return_value = MagicMock()
        service = WorkflowTemplateSnapshotService(engine_service)

        service.clone_master_as_snapshot(
            master_uid="m-uid", master_name="完整稽核流程",
            xml="<x/>", curr_user="u"
        )

        kwargs = engine_service.add_workflow_template.call_args.kwargs
        assert kwargs["name"].startswith("完整稽核流程 (snapshot ")
pytest test/test_workflow_template_snapshot_service.py -v

Expected: PASS(Spec 2 service 已實作)— 若 FAIL 表示有 regression 要先 fix

git add test/test_workflow_template_snapshot_service.py
git commit -m "test(spec3 M3): WorkflowTemplateSnapshotService unit tests (carry-forward from Spec 2)"

Task M3.2: StageAdvanceService unit test

Files:

  • Create: test/test_stage_advance_service.py
grep -n "def " app/flow_engine/service/stage_advance_service.py | grep -v "^.*def _"
"""Spec 3 M3 carry-forward — StageAdvanceService unit tests."""
import pytest
from unittest.mock import MagicMock
from app.flow_engine.service.stage_advance_service import StageAdvanceService


class TestStageAdvanceService:
    def test_advance_calls_on_complete_handler(self):
        # arrange: mock workflow_execution / handlers / preconditions
        # act: service.advance(ap_uid, stage_code, ...)
        # assert: handler 被叫到、status 推進
        ...

    def test_advance_raises_when_precondition_fails(self):
        # arrange: precondition returns False
        # act + assert: raise PreconditionFailedError(GRC_STAGE_PRECONDITION_FAILED)
        ...

    def test_advance_raises_when_no_next_node(self):
        # arrange: BPMN parser returns no outgoing flow
        # act + assert: raise BadRequest(GRC_STAGE_NO_NEXT_NODE)
        ...
pytest test/test_stage_advance_service.py -v
git add test/test_stage_advance_service.py
git commit -m "test(spec3 M3): StageAdvanceService unit tests (carry-forward from Spec 2)"

Task M3.3: oscal_stage_preconditions 個別 function unit test

Files:

  • Create: test/test_oscal_stage_preconditions.py
grep -n "^def " app/flow_engine/service/oscal_stage_preconditions.py

範例(all_tasks_completed):

def test_all_tasks_completed_true_when_all_closed():
    ap = MagicMock(tasks=[MagicMock(status="closed"), MagicMock(status="closed")])
    assert all_tasks_completed(ap) is True

def test_all_tasks_completed_false_when_any_pending():
    ap = MagicMock(tasks=[MagicMock(status="closed"), MagicMock(status="pending")])
    assert all_tasks_completed(ap) is False

Task M3.4: oscal_stage_handlers 個別 handler unit test

Files:

  • Create: test/test_oscal_stage_handlers.py

範例(activate_handler):

def test_activate_handler_calls_oscal_api(...):
    ...

def test_activate_handler_raises_when_oscal_returns_error(...):
    ...

Task M3.5: AssessmentPlanExtensionRepoImpl unit test

Files:

  • Create: test/test_assessment_plan_extension_repo_impl.py
def test_upsert_creates_when_not_exists(): ...
def test_upsert_updates_when_exists(): ...
def test_get_by_assessment_plan_id_returns_none_when_missing(): ...
def test_get_by_assessment_plan_ids_batch_returns_dict(): ...

Task M3.6: Changelog M3

docs/changelog/2026-05-13-tweak-spec2-carry-forward-tests.md:

---
type: tweak
modules: [flow_engine, infra.flow_engine]
issue: docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md (§13 M3)
---

# 補 Spec 2 carry-forward 5 service test gap

## 變更
- 新增 5 個測試檔,覆蓋 Spec 2 review 標的 5 service
- 為 M4 改動鋪測試安全網

## 測試
- WorkflowTemplateSnapshotService: 3 tests
- StageAdvanceService: N tests
- oscal_stage_preconditions: N tests
- oscal_stage_handlers: N tests
- AssessmentPlanExtensionRepoImpl: N tests

§7

M4 — BE Core: _bind_main_workflow_to_ap 改造 + 兩條 entry 加 flow_template_uid

Task M4.1: StartProjectDtoflow_template_uid 欄位

Files:

  • Modify: app/project/dto/project_dto.py (line 找 class StartProjectDto)
@dataclass
class StartProjectDto:
    # ...既有欄位...
    flow_template_uid: str = ""   # required by route schema;service 內檢查非空

test/test_oscal_project_service_flow_template.py:

def test_start_oscal_project_raises_when_flow_template_uid_empty(...):
    dto = StartProjectDto(name="x", profile_uid="p", flow_template_uid="")
    with pytest.raises(BadRequestError) as exc:
        service.start_oscal_project(dto, ...)
    assert "flow_template_uid" in str(exc.value)

app/project/service/oscal_project_service.py:151

@transaction
def start_oscal_project(
    self,
    start_project_dto: StartProjectDto,
    curr_user: str,
    curr_user_uid: str,
    curr_user_id: int = None,
    locale: str = None,
) -> ProjectDTO:
    if not start_project_dto.flow_template_uid:
        raise BadRequestError(GrcErrorCode.GRC_FLOW_TEMPLATE_UID_REQUIRED)
    # ...既有 steps...

注意:先別動 step 22;那部分到 M4.4 一起改。

common/code/grc_error_code.py(兩個新 code):

GRC_FLOW_TEMPLATE_UID_REQUIRED = ("缺少流程範本參數",      "GRC_400054")
GRC_FLOW_BINDING_DI_MISSING    = ("流程綁定服務未注入(部署錯誤)", "GRC_412025")

GRC_FLOW_BINDING_DI_MISSING 對應 M4.4 helper 內 DI null check 的 raise — 412 而非 500 表「server config 待修,重啟修好即可」非「不可恢復錯誤」。

git add app/project/dto/project_dto.py app/project/service/oscal_project_service.py common/code/grc_error_code.py test/test_oscal_project_service_flow_template.py
git commit -m "feat(spec3 M4): StartProjectDto + start_oscal_project 加 flow_template_uid required validation"

Task M4.2: start_oscal_project Route schema 加 flow_template_uid

Files:

  • Modify: api/project/routes/project_route.py(找 start project schema)
grep -rn "StartProjectRequestSchema\|class.*StartProject.*Schema" api/project/
class StartProjectRequestSchema(Schema):
    name = fields.String(required=True)
    profile_uid = fields.String(required=True)
    # ...
    flow_template_uid = fields.UUID(required=True)  # 新增
def test_post_oscal_projects_start_400_when_no_flow_template_uid(app_client, blsit_headers):
    resp = app_client.post(
        "/oscal/projects/start",
        json={"name": "x", "profile_uid": "...", ...},  # 無 flow_template_uid
        headers=blsit_headers,
    )
    assert resp.status_code == 400
pytest test/test_post_oscal_projects_start.py -v
git add api/project/routes/project_route.py test/
git commit -m "feat(spec3 M4): /oscal/projects/start 必填 flow_template_uid"

Task M4.3: launch_new_round Route schema + service 簽章加 flow_template_uid

Files:

  • Modify: api/grc/serializers/audit.py:182 (LaunchNewRoundRequestSchema)
  • Modify: api/grc/routes/audit_route.py:349 (LaunchNewRoundResource)
  • Modify: app/project/service/oscal_project_service.py:604 (launch_new_round)

api/grc/serializers/audit.py:

class LaunchNewRoundRequestSchema(Schema):
    title = fields.String(required=True)
    flow_template_uid = fields.UUID(required=True)  # 新增
    start_date = fields.Date(load_default=None, allow_none=True)
    end_date = fields.Date(load_default=None, allow_none=True)

api/grc/routes/audit_route.py:349 內 use_kwargs 解 body 後傳給 service 時加 flow_template_uid=data["flow_template_uid"]

@transaction
def launch_new_round(
    self,
    project_uid: str,
    user_id: int,
    title: str,
    flow_template_uid: str,   # 新增
    start_date=None,
    end_date=None,
    curr_user: str = None,
    locale: str = None,
) -> dict:
    if not flow_template_uid:
        raise BadRequestError(GrcErrorCode.GRC_FLOW_TEMPLATE_UID_REQUIRED)
    # ...既有 steps 1–9 不動...
    # step 10 - to be added in M4.4
def test_post_launch_new_round_400_when_no_flow_template_uid(...):
    resp = app_client.post(
        f"/grc/project/{uid}/launch-new-round",
        json={"title": "Round 2"},
        headers=blsit_headers,
    )
    assert resp.status_code == 400
git add api/grc/ app/project/service/oscal_project_service.py test/
git commit -m "feat(spec3 M4): /grc/project/<uid>/launch-new-round 必填 flow_template_uid"

Task M4.4: _bind_main_workflow_to_ap 改造 + 接 launch_new_round

Files:

  • Modify: app/project/service/oscal_project_service.py:492-543 (_bind_main_workflow_to_ap)
  • Modify: app/project/service/oscal_project_service.py:151 (start_oscal_project step 22 call)
  • Modify: app/project/service/oscal_project_service.py:604 (launch_new_round 加 step 10)
def test_start_oscal_project_binds_main_workflow_with_user_specified_template(
    app_client, blsit_headers, profile_fixture, flow_template_a
):
    resp = app_client.post(
        "/oscal/projects/start",
        json={
            "name": "Test Project",
            "profile_uid": profile_fixture.uid,
            "flow_template_uid": flow_template_a.uid,
        },
        headers=blsit_headers,
    )
    assert resp.status_code == 200
    project_uid = resp.json["data"]["uid"]

    # 驗 ap_extensions.flow_template_snapshot_uid 存在且不等於 master uid
    ext = session.query(AssessmentPlanExtension).filter_by(...).first()
    assert ext.flow_template_snapshot_uid is not None
    assert ext.flow_template_snapshot_uid != flow_template_a.uid

    # 驗 snapshot.source_template_uid == master uid
    snap = session.query(WorkflowTemplate).filter_by(uid=ext.flow_template_snapshot_uid).first()
    assert snap.source_template_uid == flow_template_a.uid

def test_launch_new_round_binds_main_workflow_independently(
    app_client, blsit_headers, project_with_closed_ap1, flow_template_b
):
    """Spec 2 gap fix — launch_new_round 也要綁 main workflow,跟第一輪 snapshot 不同。"""
    resp = app_client.post(
        f"/grc/project/{project_with_closed_ap1.uid}/launch-new-round",
        json={"title": "Round 2", "flow_template_uid": flow_template_b.uid},
        headers=blsit_headers,
    )
    assert resp.status_code == 200
    ap2_uid = resp.json["data"]["ap_uid"]

    ap1_ext = session.query(AssessmentPlanExtension).filter_by(assessment_plan_id=ap1_id).first()
    ap2_ext = session.query(AssessmentPlanExtension).filter_by(assessment_plan_id=ap2_id).first()

    assert ap1_ext.flow_template_snapshot_uid != ap2_ext.flow_template_snapshot_uid
    assert ap1_ext.workflow_execution_uid != ap2_ext.workflow_execution_uid

def test_invalid_flow_template_uid_returns_404(...):
    resp = app_client.post(
        "/oscal/projects/start",
        json={"name": "x", "profile_uid": "p", "flow_template_uid": "00000000-0000-0000-0000-000000000000"},
        headers=blsit_headers,
    )
    assert resp.status_code == 404
    assert resp.json["msg"].startswith("流程範本不存在")

def test_soft_deleted_flow_template_returns_404(...):
    # arrange: flow_template_a is_active=False
    # ...
    assert resp.status_code == 404

Expected: FAIL because service 還沒接 flow_template_uid_bind_main_workflow_to_ap

def _bind_main_workflow_to_ap(
    self,
    ap,
    project_uid: str,
    flow_template_uid: str,   # 新增 required
    curr_user: str,
) -> None:
    """Spec 3 — 用 caller 指定的 flow_template_uid 取 master,clone 一份 per-AP
    凍結 snapshot 到 engine 表,啟動 workflow_execution,寫 assessment_plan_extensions。

    Caller 已開 @transaction scope;本 helper 不再加。
    """
    # DI 缺失視為 server misconfig(部署錯)— 不該 silent skip 讓 AP 建得起來但
    # Banner 後續永遠 404。沿用 Spec 2 step 22 review fix H2 的做法 raise 412
    # 讓 user / dev 立刻看到 root cause。
    # 用既有 GRC_AP_NO_WORKFLOW_BINDING (404029) 不準確(語意是「AP 沒綁 workflow」
    # 而非「部署層 DI 壞」)。Spec 3 新增專用 code:
    if (self._flow_template_domain_service is None
            or self._ap_extension_domain_service is None
            or self._workflow_template_snapshot_service is None):
        raise PreconditionFailedError(GrcErrorCode.GRC_FLOW_BINDING_DI_MISSING)

    master = self._flow_template_domain_service.get_by_uid(flow_template_uid)
    if master is None or not master.is_active:
        raise NotFound(GrcErrorCode.GRC_FLOW_TEMPLATE_NOT_FOUND)

    snapshot = self._workflow_template_snapshot_service.clone_master_as_snapshot(
        master_uid=str(master.uid),
        master_name=master.name,
        xml=master.bpmn_xml,
        description=master.description,
        curr_user=curr_user,
    )

    main_wf = self.workflow_execution_service.start_workflow_execution(
        template_id=snapshot.id,
        name=f"AP-{ap.uid}-main",
        params={"ap_uid": str(ap.uid), "project_uid": project_uid},
        created_user=curr_user,
        description=f"AP main workflow for {ap.title}",
    )

    self._ap_extension_domain_service.upsert(
        assessment_plan_id=ap.id,
        workflow_execution_uid=str(main_wf.uid),
        flow_template_snapshot_uid=str(snapshot.uid),
        curr_user=curr_user,
    )
    logger.info(
        f"[bind_main_workflow] ap_uid={ap.uid} bound to "
        f"workflow_execution_uid={main_wf.uid} snapshot_uid={snapshot.uid} "
        f"master_uid={master.uid}"
    )

app/project/service/oscal_project_service.py:487

self._bind_main_workflow_to_ap(
    ap=ap,
    project_uid=str(project.uid),
    flow_template_uid=start_project_dto.flow_template_uid,  # 新增
    curr_user=curr_user,
)

app/project/service/oscal_project_service.py:754(既有 return 之前):

# Step 10 (Spec 3) - 補 Spec 2 漏蓋:launch_new_round 也要綁 main workflow snapshot
self._bind_main_workflow_to_ap(
    ap=ap,
    project_uid=str(project_entity.uid),
    flow_template_uid=flow_template_uid,
    curr_user=curr_user,
)
pytest test/test_oscal_project_service_flow_template.py -v

Expected: 4 tests PASS

pkill -9 -f main_socketio.py && python main_socketio.py > /dev/null 2>&1 &
tail -f log/app.log
# 另開 terminal 試 curl
git add app/project/service/oscal_project_service.py test/
git commit -m "feat(spec3 M4): _bind_main_workflow_to_ap 接 flow_template_uid + launch_new_round 補綁 main workflow"

Task M4.5: 拔除 Spec 2 hardcode default lookup

Files:

  • Modify: domain/flow_engine/service/flow_template_domain_service.py:30-36
  • Modify: domain/flow_engine/repository/flow_template_repo.py:28
  • Modify: infra/flow_engine/repository/flow_template_repo_impl.py:88
grep -rn "get_default_main_workflow_template\|get_builtin_by_name\|DEFAULT_MAIN_WORKFLOW_TEMPLATE_NAME" --include="*.py"

Expected: 只剩定義處跟內部呼叫,沒有外部 caller

domain/flow_engine/service/flow_template_domain_service.py — 刪 line 30-36(DEFAULT_MAIN_WORKFLOW_TEMPLATE_NAME 常數 + get_default_main_workflow_template method)

domain/flow_engine/repository/flow_template_repo.py:28 — 刪 def get_builtin_by_name(...) ...

infra/flow_engine/repository/flow_template_repo_impl.py:88 — 刪整個 def get_builtin_by_name(...) 實作 body

pytest test/ -v 2>&1 | tail -30

Expected: 全綠

python -c "from app.project.service.oscal_project_service import OscalProjectService; print('ok')"
git add domain/flow_engine/ infra/flow_engine/
git commit -m "tweak(spec3 M4): 拔除 Spec 2 過渡 hardcode default workflow lookup"

Task M4.6: Changelog M4

docs/changelog/2026-05-13-feat-spec3-flow-template-required.md:

---
type: feat
breaking: true
modules: [project, grc, flow_engine]
issue: docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md (§9.1-9.5)
---

# Spec 3:兩條 entry 必填 flow_template_uid + launch_new_round 補綁 main workflow

## 變更
- POST /oscal/projects/start 必填 `flow_template_uid` (UUID)
- POST /grc/project/<uid>/launch-new-round 必填 `flow_template_uid` (UUID)
- launch_new_round 現在會綁 main workflow snapshot(補 Spec 2 漏蓋)
- 拔除 hardcode default lookup(FlowTemplateDomainService.get_default_main_workflow_template / DEFAULT_MAIN_WORKFLOW_TEMPLATE_NAME / IFlowTemplateRepo.get_builtin_by_name 全刪)

## Breaking
- 既有 FE 呼叫不帶 `flow_template_uid` 會 400;本 commit 與 FE M5/M6 必須一起部署
- 若 dev DB 既有 round-1 AP 有效運作(snapshot 已建好),不需 backfill;舊 AP 仍用舊 snapshot

## 測試
- 4 integration test 全 pass(含跨 round snapshot 隔離驗證)

§8

M5 — FE ProjectCreateView Dropdown + Preview

切到 FE repo 工作。

cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe

Task M5.1: FlowTemplateService 確認 list API 可呼叫

Files: 純驗證

grep -rn "flow-engine/flow-templates" src/ | head -5

Expected: 看到 src/service/FlowTemplateService.js 或類似 file 已包好 list / detail

# 用瀏覽器 DevTool 在 /flow/template-manage 頁面手動觀察 list API call

Task M5.2: ProjectCreateView 加流程範本 dropdown

Files:

  • Modify: src/views/project/ProjectCreateView.vue
  • Modify: src/components/grc/ProjectBasicInfoForm.vue(如果 dropdown 加在這裡)

M0 已 grep 完 ProjectCreateView.vue / ProjectBasicInfoForm.vue,dropdown 落點已定。 若 M0 結論:dropdown 在 ProjectBasicInfoForm,則 modify src/components/grc/ProjectBasicInfoForm.vue; 若結論:在 ProjectCreateView inline,則 modify src/views/project/ProjectCreateView.vue。 本 plan 範例以 ProjectBasicInfoForm 為例(若實際是 inline,把 form/v$ 改成 props 取代)。

<script setup> 部分:

import { ref, computed, onMounted } from 'vue'
import FlowTemplateService from '@/service/FlowTemplateService'
import FlowTemplatePreviewDialog from '@/views/flow-template/components/FlowTemplatePreviewDialog.vue'

const flowTemplates = ref([])
const loadingTemplates = ref(false)
const previewVisible = ref(false)

const loadFlowTemplates = async () => {
  loadingTemplates.value = true
  try {
    const res = await FlowTemplateService.getList({ is_active: true })
    flowTemplates.value = res.data || []
  } finally {
    loadingTemplates.value = false
  }
}

onMounted(loadFlowTemplates)

template 部分(加在 profile_uid dropdown 下方):

<div class="field">
  <label for="flow-template" class="required">第一輪稽核計畫流程範本</label>
  <div class="flex gap-2">
    <Dropdown
      id="flow-template"
      v-model="form.flowTemplateUid"
      :options="flowTemplates"
      option-label="name"
      option-value="uid"
      :loading="loadingTemplates"
      :placeholder="$t('project.flowTemplate.placeholder')"
      :class="{ 'p-invalid': v$.flowTemplateUid.$error }"
      class="flex-1"
    />
    <Button
      icon="pi pi-search"
      :label="$t('project.flowTemplate.detailBtn')"
      text
      size="small"
      :disabled="!form.flowTemplateUid"
      @click="previewVisible = true"
    />
  </div>
  <small v-if="v$.flowTemplateUid.$error" class="p-error">
    {{ $t('project.flowTemplate.requiredError') }}
  </small>
</div>

<FlowTemplatePreviewDialog
  v-model:visible="previewVisible"
  :template-uid="form.flowTemplateUid"
/>
const rules = computed(() => ({
  // ...
  flowTemplateUid: { required: helpers.withMessage(t('project.flowTemplate.requiredError'), required) },
}))

確認 submit handler 把 form.flowTemplateUid 放到 POST body:

await ProjectService.createProject({
  name: form.name,
  profile_uid: form.profileUid,
  // ...
  flow_template_uid: form.flowTemplateUid,  // 新增
})

src/i18n/locales/zh-TW.json:

"project": {
  "flowTemplate": {
    "label": "流程範本",
    "firstRoundLabel": "第一輪稽核計畫流程範本",
    "placeholder": "請選擇流程範本",
    "detailBtn": "詳細選擇",
    "requiredError": "請選擇流程範本",
    "inheritedHint": "預設沿用上一輪稽核計畫的流程範本"
  }
}

en.json 對應補。

npm run dev
# 開 http://localhost:5173/project/projects/new
# 走完整 wizard,看 dropdown / 預覽 Dialog
# 提交 → 確認後端 200 + ap_extensions 有 row
git add src/views/project/ src/components/grc/ src/i18n/
git commit -m "feat(spec3 M5): ProjectCreateView 加流程範本 dropdown + 預覽 Dialog + i18n"

Task M5.3: Changelog M5

依 M0 Step 7 verification 結論:

  • 若 FE repo 有 docs/changelog/ → 在該 repo create 2026-05-13-feat-project-create-flow-template.md
  • 若無 → 在 BE repo docs/changelog/2026-05-13-feat-spec3-flow-template-required.md(M4.6 那筆)追加 M5 段,標 modules: [project, frontend]

§9

M6 — FE ProjectApListView Dialog Dropdown + Default 帶值

Task M6.1: ProjectApListView「新增稽核計畫」Dialog 加 dropdown + default

Files:

  • Modify: src/views/project/ProjectApListView.vue
grep -n "新增稽核計畫\|newRound\|launchNewRound\|launch-new-round\|launch_new_round" src/views/project/ProjectApListView.vue | head

<script setup>

import FlowTemplateService from '@/service/FlowTemplateService'
import FlowTemplatePreviewDialog from '@/views/flow-template/components/FlowTemplatePreviewDialog.vue'

const flowTemplates = ref([])
const previewVisible = ref(false)
const newRoundForm = ref({
  title: '',
  flowTemplateUid: null,
  startDate: null,
  endDate: null,
})

// Default 帶值(依賴 M2 — apList[0].flow_template enrichment)
const defaultFlowTemplateUid = computed(() => {
  const latest = props.apList?.[0]
  return latest?.flow_template?.master_uid ?? null
})

watch(() => dialogVisible.value, (v) => {
  if (v) {
    newRoundForm.value.flowTemplateUid = defaultFlowTemplateUid.value
  }
})

onMounted(async () => {
  flowTemplates.value = (await FlowTemplateService.getList({ is_active: true })).data
})

template Dialog body:

<div class="field">
  <label for="round-flow-template" class="required">
    {{ $t('project.flowTemplate.label') }}
  </label>
  <div class="flex gap-2">
    <Dropdown
      id="round-flow-template"
      v-model="newRoundForm.flowTemplateUid"
      :options="flowTemplates"
      option-label="name"
      option-value="uid"
      :placeholder="$t('project.flowTemplate.placeholder')"
      class="flex-1"
    />
    <Button
      icon="pi pi-search"
      :label="$t('project.flowTemplate.detailBtn')"
      text size="small"
      :disabled="!newRoundForm.flowTemplateUid"
      @click="previewVisible = true"
    />
  </div>
  <small v-if="defaultFlowTemplateUid && newRoundForm.flowTemplateUid === defaultFlowTemplateUid"
         class="text-muted">
    {{ $t('project.flowTemplate.inheritedHint') }}
  </small>
</div>

<FlowTemplatePreviewDialog
  v-model:visible="previewVisible"
  :template-uid="newRoundForm.flowTemplateUid"
/>
await ProjectApService.launchNewRound(projectUid, {
  title: newRoundForm.value.title,
  flow_template_uid: newRoundForm.value.flowTemplateUid,
  start_date: newRoundForm.value.startDate,
  end_date: newRoundForm.value.endDate,
})
# /project/projects/<uid> 開啟 → 新增稽核計畫 Dialog
# 確認 dropdown default 帶到「最新一輪 AP 範本」
# 提交 → 後端 200 + ap_extensions 第二筆 row(不同 snapshot uid)
git add src/views/project/ProjectApListView.vue
git commit -m "feat(spec3 M6): ProjectApListView 新增稽核計畫 Dialog 加流程範本 dropdown + default 帶值"

§10

M7 — Manual Smoke(必過項)

Task M7.1: 跑兩個不同範本的 end-to-end smoke

# BE
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
pkill -9 -f main_socketio.py && python main_socketio.py > /dev/null 2>&1 &
tail -f log/app.log &  # 監看
# FE
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
npm run dev

走 wizard 全程,AP1 建出來。

依 Banner 推進 4 個 stage:

  • preparing → active(推進按鈕:啟動專案)
  • active → auditing(啟動稽核)
  • auditing → remediation(提交稽核)
  • remediation → closed(完成改善)

每個 stage 都驗:

  • AP.status 對應
  • Banner stage code 對應 BPMN 當前 UserTask

確認 default 帶到「完整稽核流程」(最新一輪 master),改選 B。提交。

範本 B 是「規劃 → 執行 → 稽核 → End」少 poam stage。 驗 Banner stage 流轉:

  • preparing → active → auditing → closed(跳過 remediation)
-- 兩列 ap_extensions
SELECT assessment_plan_id, flow_template_snapshot_uid, workflow_execution_uid
FROM compliance.assessment_plan_extensions
WHERE assessment_plan_id IN (
  SELECT id FROM oscal.assessment_plans WHERE project_id = <project_id>
);
-- → 兩 row,flow_template_snapshot_uid 互不相同

-- 兩列 snapshot
SELECT id, uid, name, source_template_uid, provider, version
FROM public.workflow_templates
WHERE uid IN (
  SELECT flow_template_snapshot_uid FROM compliance.assessment_plan_extensions
  WHERE assessment_plan_id IN (<ap1_id>, <ap2_id>)
);
-- → 兩 row,provider='snapshot',source_template_uid 分別指向 master A / B

-- 兩個 workflow_executions
SELECT uid, name, template_id, status
FROM public.workflow_executions
WHERE uid IN (
  SELECT workflow_execution_uid FROM compliance.assessment_plan_extensions
  WHERE assessment_plan_id IN (<ap1_id>, <ap2_id>)
);
-- → 兩 row,互不相關
-- 改 master A 的 BPMN XML(模擬 admin 編輯)
UPDATE compliance.flow_templates SET bpmn_xml = '<bpmn>...modified...</bpmn>'
WHERE name = '完整稽核流程';

-- 驗 snapshot row 不變
SELECT xml FROM public.workflow_templates WHERE uid = '<ap1_snapshot_uid>';
-- → 仍是 build 時的原 XML,不被 master 變更影響

按 CLAUDE.md「重大決策完成後自動歸檔思考過程」開 docs/analysis/2026-05-13-spec3-smoke-failure-root-cause.md

Task M7.2: 收尾 changelog 補 M7 結果

docs/changelog/2026-05-13-feat-spec3-flow-template-required.md 加段:

## 驗證結果(M7 smoke)
- Round 1(範本 A,4 stages 全跑):✅
- Round 2(範本 B,跳 poam):✅
- Snapshot 隔離(改 master 不影響 AP):✅
- DB 驗證(兩 row ext / 兩 row snapshot / 兩 row workflow_execution):✅
git add docs/changelog/2026-05-13-feat-spec3-flow-template-required.md
git commit -m "docs(spec3 M7): smoke 驗證結果補進 changelog"

§11

M8 — Phase E 4 Fixture Bug 修補(事後)

跨 repo 工作,需切到 compliance-manager-test repo。本 plan 不展開,僅標範圍。 詳見:docs/issues/pending/2026-05-13-test-fixture-broken-by-spec2-phase-d-and-fe-refactor.md


§12

全 Spec 3 task arc 收尾

Task: 套件發版(user 明確同意才動)

Task: SUMMARY + conversation history

Task: 跨 repo review


§13

完成標準(Definition of Done)