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: 在既有 stage_object / BPMN flow template 機制上新增 review 階段(reviewer 主導 approve/reject),同時為範本 create/update 加方向性驗證(DAG 規則 + reverse edge 例外),並讓所有 stage advance 共用留言。
Architecture: 兩個子題共動 compliance.stage_objects schema 與 FlowTemplateAppService / StageAdvanceService 兩個入口。Review reverse 走既有 BPMN ExclusiveGateway + condition_param 機制(不改 jedi-flow-engine 套件);validator 是 pure function 易於 unit test;留言 reuse JobCommentService。
Tech Stack:
Spec 來源: docs/features/FR-026-2605-project-flow-engine-integrate/04-review-stage-and-flow-validation/design.md v1.1
docs/features/FR-026-2605-project-flow-engine-integrate/04-review-stage-and-flow-validation/design.md — Spec 4 設計 v1.1(必讀)docs/features/FR-026-2605-project-flow-engine-integrate/02-stage-integration/design.md — Spec 2 已 ship;§十 reconciliation 10 條落地偏差是 spec 4 銜接點docs/features/FR-026-2605-project-flow-engine-integrate/01-flow-template-management/design.md — Spec 1 已 ship;stage_object schema + validator 進入點docs/features/FR-026-2605-project-flow-engine-integrate/03-ap-binding/design.md — Spec 3 已 ship;binding patternCLAUDE.md — DDD / @transaction / Error code / API patterns / changelog 規範docs/claude/jedi-packages.md — jedi-flow-engine API 參考(BpmnUtils / condition_param)docs/claude/frontend-overview.md — FE 設計 cheatsheet(design tokens / PrimeVue quirks)feature/project-flow-engine-review branch (HEAD = 4a782dc 之後)pyproject.toml jedi-flow-engine 已 pin 0.0.27(v1 不需 path mode,因不改套件)python main_socketio.py 跑得起來(port 8000);日誌 log/app.logpsql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev(密碼 jedi@123!;migration 一律用 cmmgr 不用 cm_app)compliance-manager-fe 同 branch;npm run dev 起 5173compliance-manager-test 跟同 branchblsadmin / Billows@123!(manual);blsit / Billows@123!(pytest)純檢查,不修改任何檔案。確認既有實作的假設是 plan 寫對。
psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c "\d compliance.stage_objects"Expected: 含 id / uid / code / name_i18n / kind / route_pattern / complete_button_label_i18n / complete_handler_key / precondition_key / default_main_roles / is_builtin / is_active / sort / created_user / updated_user / created_at / updated_at 17 個欄位。不含 can_start / can_end / allowed_predecessors / allowed_successors — 確認 M1 要加的 4 欄不存在。
psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c \
"SELECT id, code, kind, complete_handler_key FROM compliance.stage_objects ORDER BY sort;"Expected: 4 列(planning / task_execution / audit / poam),無 review。
grep -A 2 "Flow_poam_audit" scripts/sql/seeds/bpmn/builtin-full-audit.bpmnExpected: 找到 <bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>,無 <camunda:property name="reverse" value="true"/>。確認 M4 patch 是必要的。
python -c "from jedi_flow_engine.common.utils.bpmn_uilts import BpmnUtils; import inspect; print(inspect.getsourcefile(BpmnUtils))"Expected: 印出 jedi-flow-engine 套件路徑。再 grep 該檔的 _parse_condition_param_to_string:
grep -A 5 "_parse_condition_param_to_string" $(python -c "from jedi_flow_engine.common.utils.bpmn_uilts import BpmnUtils; import inspect; print(inspect.getsourcefile(BpmnUtils))")Expected: 輸出 lowercase + Camunda ${} 格式(Spec 2 §十.9 修正後)。若不是,整個 reject 機制需先補套件 — 但這應該已是 0.0.27 既有行為。
grep -A 2 "JobExecution\.uid ==" infra/grc/repository/grc_job_comment_repo_impl.pyExpected: 印出 .filter(JobExecution.uid == job_uid) — 確認 repo 期望 UUID,跟 design §8.2 一致。
python -c "
from jedi_flow_engine.common.utils.bpmn_uilts import BpmnUtils
import inspect
print(inspect.getsource(BpmnUtils.get_job_by_id))
"Expected: 回傳的物件含 id 欄位(BPMN element string id,如 UserTask_review)。確認傳這個 id 到 JobCommentService 會 silent fail。
sed -n '365,380p' di_containers/grc/grc_containers.pyExpected: 列出 8 個現有 dependency;job_comment_service 不在內 — 確認 M6 需加新 dep。
sed -n '378,395p' di_containers/grc/grc_containers.pyExpected: 看到 5 個 handler register + 4 個 precondition register。確認 M6 加 review_decision_handler() 註冊的位置。
grep "GRC_4006" common/code/grc_error_code.pyExpected: 0 hits。確認 GRC_400060 / 400061 / 400062 可用。
Files:
scripts/sql/2026-05-14-spec4-stage-objects-rules-and-review.sql-- Date: 2026-05-14
-- Spec 4 — stage_objects 加 4 欄方向性驗證 + seed review stage
-- 對應 design.md §5
-- 1. compliance.stage_objects 加 4 欄 (2026-05-14)
ALTER TABLE compliance.stage_objects
ADD COLUMN IF NOT EXISTS can_start BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS can_end BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS allowed_predecessors JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS allowed_successors JSONB NOT NULL DEFAULT '[]'::jsonb;
-- 2. 4 個既有 stage 規則回填 (2026-05-14)
UPDATE compliance.stage_objects
SET can_start = TRUE,
can_end = TRUE,
allowed_predecessors = '[]'::jsonb,
allowed_successors = '["task_execution", "review", "audit", "poam"]'::jsonb
WHERE code = 'planning';
UPDATE compliance.stage_objects
SET can_start = FALSE,
can_end = TRUE,
allowed_predecessors = '["planning", "review"]'::jsonb,
allowed_successors = '["audit", "review"]'::jsonb
WHERE code = 'task_execution';
UPDATE compliance.stage_objects
SET can_start = FALSE,
can_end = TRUE,
allowed_predecessors = '["task_execution", "review", "poam"]'::jsonb,
allowed_successors = '["poam"]'::jsonb
WHERE code = 'audit';
UPDATE compliance.stage_objects
SET can_start = FALSE,
can_end = TRUE,
allowed_predecessors = '["audit"]'::jsonb,
allowed_successors = '[]'::jsonb
WHERE code = 'poam';
-- 3. Seed review stage (2026-05-14)
INSERT INTO compliance.stage_objects (
code, name_i18n, kind, route_pattern,
complete_button_label_i18n, complete_handler_key, precondition_key,
default_main_roles, is_builtin, sort,
can_start, can_end, allowed_predecessors, allowed_successors
) VALUES (
'review',
'{"zh_Hant_TW": "審核", "en": "Review"}'::jsonb,
'stateful', NULL,
'{"zh_Hant_TW": "送出審核", "en": "Submit Review"}'::jsonb,
'review_decision', NULL,
'["reviewer"]'::jsonb, TRUE, 25,
FALSE, TRUE,
'["planning", "task_execution"]'::jsonb,
'["audit"]'::jsonb
) ON CONFLICT (code) DO NOTHING;
-- 4. 驗證 SQL(manual run,不影響 migration)
-- SELECT code, can_start, can_end, allowed_predecessors, allowed_successors
-- FROM compliance.stage_objects ORDER BY sort;set -a && source .env && set +a
PGPASSWORD="$(echo $DB_SECRET | python -c 'import sys,json;print(json.load(sys.stdin)["rds_master_password"])')" \
psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
-f scripts/sql/2026-05-14-spec4-stage-objects-rules-and-review.sqlExpected: 4 個 ALTER TABLE + 4 個 UPDATE 1 + 1 個 INSERT 0 1。若 INSERT 0 0 表示 review 已存在(M0.1 應該確認過不該發生)。
psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c \
"SELECT code, can_start, can_end, allowed_predecessors::text, allowed_successors::text FROM compliance.stage_objects ORDER BY sort;"Expected: 5 列(planning sort=10 / task_execution sort=20 / review sort=25 / audit sort=30 / poam sort=40);4 欄值對應 design §5.2。
git add scripts/sql/2026-05-14-spec4-stage-objects-rules-and-review.sql
git commit -m "$(cat <<'EOF'
feat(spec4-M1): stage_objects 加方向性驗證 4 欄 + seed review stage
對應 spec 4 design.md §5:
- 加 can_start / can_end / allowed_predecessors / allowed_successors 4 欄
- 回填 4 個既有 stage 規則(planning 唯一 can_start=true)
- seed 第 5 個 stage 'review' (kind=stateful, default_main_roles=[reviewer])
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Files:
infra/flow_engine/models/stage_object.py can_start: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否可作為流程起點")
can_end: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否可直接接 EndEvent")
allowed_predecessors: Mapped[list] = mapped_column(JSONB, nullable=False, default=list, comment="允許的 forward predecessor stage code 清單")
allowed_successors: Mapped[list] = mapped_column(JSONB, nullable=False, default=list, comment="允許的 forward successor stage code 清單")Files:
domain/flow_engine/entity/stage_object_entity.py can_start: bool = False
can_end: bool = True
allowed_predecessors: list = field(default_factory=list)
allowed_successors: list = field(default_factory=list)Files:
infra/flow_engine/mapper/stage_object_mapper.py sort=m.sort,
can_start=m.can_start,
can_end=m.can_end,
allowed_predecessors=m.allowed_predecessors or [],
allowed_successors=m.allowed_successors or [],
created_user=m.created_user, m.sort = e.sort
m.can_start = e.can_start
m.can_end = e.can_end
m.allowed_predecessors = e.allowed_predecessors
m.allowed_successors = e.allowed_successors
m.created_user = e.created_userlsof -ti :8000 | xargs kill -9 2>/dev/null; sleep 1
nohup python main_socketio.py > /tmp/be.log 2>&1 &
sleep 3 && tail -20 log/app.logExpected: 無 SQLAlchemy column 錯誤;INFO 級別 startup log。
TOKEN=$(curl -s -X POST http://localhost:8000/login \
-H "Content-Type: application/json" \
-d '{"login_name":"blsadmin","password":"Billows@123!"}' | python -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/flow-engine/stage-objects | python -m json.tool | head -30Expected: 回 5 個 stage(含 review);但 4 個新欄位還沒出現在 response — 由 M2.5 決定是否曝。
Files:
app/flow_engine/dto/stage_object_dto.py新欄位用於 BE 內部 validator,FE 不需要展示給 user。v1 不曝到 DTO;如未來 FE editor 想顯示「此 stage 允許接 X / Y」hint,再加。skip 這個 task。
git add infra/flow_engine/models/stage_object.py \
domain/flow_engine/entity/stage_object_entity.py \
infra/flow_engine/mapper/stage_object_mapper.py
git commit -m "$(cat <<'EOF'
feat(spec4-M2): stage_object entity / mapper / model 對應 4 個新欄位
把 M1 加的 4 個 DB 欄位映射到 ORM model / entity dataclass / mapper。
DTO 暫不曝(validator 內部用),FE 不需展示。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"BpmnTopologyValidator 實作 + unit testFiles:
tests/test_bpmn_topology_validator.py"""BpmnTopologyValidator unit tests (Spec 4 design.md §7).
純 function 測試,不需 DB;mock stage_object 規則。
"""
import re
import pytest
from app.flow_engine.util.bpmn_topology_validator import (
BpmnTopologyValidator,
StageRules,
ValidationViolation,
)
@pytest.fixture
def default_rules():
"""5 stage 規則(mirror M1 migration seed)"""
return {
"planning": StageRules(
can_start=True, can_end=True,
allowed_predecessors=[],
allowed_successors=["task_execution", "review", "audit", "poam"],
),
"task_execution": StageRules(
can_start=False, can_end=True,
allowed_predecessors=["planning", "review"],
allowed_successors=["audit", "review"],
),
"review": StageRules(
can_start=False, can_end=True,
allowed_predecessors=["planning", "task_execution"],
allowed_successors=["audit"],
),
"audit": StageRules(
can_start=False, can_end=True,
allowed_predecessors=["task_execution", "review", "poam"],
allowed_successors=["poam"],
),
"poam": StageRules(
can_start=False, can_end=True,
allowed_predecessors=["audit"],
allowed_successors=[],
),
}
def _read_builtin_xml(name: str) -> str:
from pathlib import Path
p = Path("scripts/sql/seeds/bpmn") / f"{name}.bpmn"
return p.read_text(encoding="utf-8")
# ── Pass cases ───────────────────────────────────────────────────────────
def test_builtin_internal_check_passes(default_rules):
xml = _read_builtin_xml("builtin-internal-check")
violations = BpmnTopologyValidator(default_rules).validate(xml)
assert violations == []
def test_builtin_self_assessment_passes(default_rules):
xml = _read_builtin_xml("builtin-self-assessment")
violations = BpmnTopologyValidator(default_rules).validate(xml)
assert violations == []
def test_builtin_full_audit_passes_after_reverse_patch(default_rules):
"""M4 patch 後此 test 才會過;現在會 fail with successor_not_allowed (poam→audit)"""
xml = _read_builtin_xml("builtin-full-audit")
violations = BpmnTopologyValidator(default_rules).validate(xml)
assert violations == [], f"unexpected violations: {violations}"
# ── Fail cases ───────────────────────────────────────────────────────────
def _xml_with_start_to_poam():
return """<?xml version="1.0"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:camunda="http://camunda.org/schema/1.0/bpmn">
<bpmn:process id="P" isExecutable="true">
<bpmn:startEvent id="S"><bpmn:outgoing>F1</bpmn:outgoing></bpmn:startEvent>
<bpmn:userTask id="UT_p">
<bpmn:extensionElements><camunda:properties>
<camunda:property name="stage_object_code" value="poam"/>
</camunda:properties></bpmn:extensionElements>
<bpmn:incoming>F1</bpmn:incoming><bpmn:outgoing>F2</bpmn:outgoing>
</bpmn:userTask>
<bpmn:endEvent id="E"><bpmn:incoming>F2</bpmn:incoming></bpmn:endEvent>
<bpmn:sequenceFlow id="F1" sourceRef="S" targetRef="UT_p"/>
<bpmn:sequenceFlow id="F2" sourceRef="UT_p" targetRef="E"/>
</bpmn:process>
</bpmn:definitions>"""
def test_start_stage_not_allowed(default_rules):
xml = _xml_with_start_to_poam()
violations = BpmnTopologyValidator(default_rules).validate(xml)
assert any(v.rule == "start_stage_not_allowed" for v in violations)
def test_unknown_stage_code(default_rules):
"""UserTask 帶不存在的 stage_object_code"""
xml = _xml_with_start_to_poam().replace('value="poam"', 'value="nonexistent"')
violations = BpmnTopologyValidator(default_rules).validate(xml)
assert any(v.rule == "unknown_stage_code" for v in violations)
# ── §12.1 reverse regex 邊界 case(design §7.3)─────────────────────────
@pytest.mark.parametrize("condition,expected_reverse", [
("${decision == 'reject'}", True),
("${review_status == 'reject'}", True),
("${ decision == 'reject' }", True),
("${review_status == 'rejected_in_form'}", False), # false positive 防範
("${decision == 'Reject'}", False), # 大小寫敏感
('${decision == "reject"}', True), # 雙引號
("${decision != 'approve'}", False), # 非語意推導
("${a == 'reject' && b == 'foo'}", False), # 複合條件
("", False), # 空字串
])
def test_reverse_condition_regex(condition, expected_reverse):
from app.flow_engine.util.bpmn_topology_validator import is_reverse_condition
assert is_reverse_condition(condition) is expected_reverse
# ── reachability — reverse edge 不算路徑 ─────────────────────────────────
def test_no_end_path_pure_reverse_cycle(default_rules):
"""A → B 一條 forward,B → A 一條 reverse;沒有 forward 到 EndEvent 的路徑"""
xml = """<?xml version="1.0"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:camunda="http://camunda.org/schema/1.0/bpmn">
<bpmn:process id="P" isExecutable="true">
<bpmn:startEvent id="S"><bpmn:outgoing>F1</bpmn:outgoing></bpmn:startEvent>
<bpmn:userTask id="UT_a">
<bpmn:extensionElements><camunda:properties>
<camunda:property name="stage_object_code" value="planning"/>
</camunda:properties></bpmn:extensionElements>
<bpmn:incoming>F1</bpmn:incoming><bpmn:incoming>F3</bpmn:incoming>
<bpmn:outgoing>F2</bpmn:outgoing>
</bpmn:userTask>
<bpmn:userTask id="UT_b">
<bpmn:extensionElements><camunda:properties>
<camunda:property name="stage_object_code" value="task_execution"/>
</camunda:properties></bpmn:extensionElements>
<bpmn:incoming>F2</bpmn:incoming><bpmn:outgoing>F3</bpmn:outgoing>
</bpmn:userTask>
<bpmn:endEvent id="E"/>
<bpmn:sequenceFlow id="F1" sourceRef="S" targetRef="UT_a"/>
<bpmn:sequenceFlow id="F2" sourceRef="UT_a" targetRef="UT_b"/>
<bpmn:sequenceFlow id="F3" sourceRef="UT_b" targetRef="UT_a">
<bpmn:extensionElements><camunda:properties>
<camunda:property name="reverse" value="true"/>
</camunda:properties></bpmn:extensionElements>
</bpmn:sequenceFlow>
</bpmn:process>
</bpmn:definitions>"""
violations = BpmnTopologyValidator(default_rules).validate(xml)
assert any(v.rule == "no_end_path" for v in violations)pytest tests/test_bpmn_topology_validator.py -v 2>&1 | head -30Expected: ModuleNotFoundError: No module named 'app.flow_engine.util.bpmn_topology_validator'
Files:
app/flow_engine/util/bpmn_topology_validator.py"""BPMN topology validator (Spec 4 design §7).
Pure function — input BPMN XML + stage rules dict, output list of violations.
不依賴 DB / Flask context;caller (FlowTemplateAppService) 負責從 DB 拉規則。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set
from jedi_flow_engine.common.utils.bpmn_uilts import BpmnUtils
# ── Data structures ─────────────────────────────────────────────────────────
@dataclass
class StageRules:
can_start: bool = False
can_end: bool = True
allowed_predecessors: List[str] = field(default_factory=list)
allowed_successors: List[str] = field(default_factory=list)
@dataclass
class ValidationViolation:
rule: str # e.g. "successor_not_allowed"
reason_i18n_key: str # e.g. "flow_engine.validator.successor_not_allowed"
context: Dict[str, str] = field(default_factory=dict)
# ── Reverse detection(design §7.3)──────────────────────────────────────────
_REVERSE_CONDITION_REGEX = re.compile(
r"^\s*\$\{\s*[A-Za-z_][A-Za-z_0-9]*\s*==\s*['\"]reject['\"]\s*\}\s*$"
)
def is_reverse_condition(condition: Optional[str]) -> bool:
"""strict anchored match for ${var == 'reject'} forms only."""
if not condition:
return False
return _REVERSE_CONDITION_REGEX.fullmatch(condition) is not None
# ── Validator ───────────────────────────────────────────────────────────────
class BpmnTopologyValidator:
"""Validates BPMN XML against stage rules. design.md §7.2 7 步檢查."""
def __init__(self, stage_rules: Dict[str, StageRules]):
self._rules = stage_rules
def validate(self, xml: str) -> List[ValidationViolation]:
violations: List[ValidationViolation] = []
try:
util = BpmnUtils(xml)
except Exception:
return [ValidationViolation(
rule="bpmn_parse_failed",
reason_i18n_key="flow_engine.validator.bpmn_parse_failed",
)]
# 收集 BPMN element 索引
process_dict = util.workflow or {}
sequence_flows = self._collect_sequence_flows(process_dict)
user_tasks = self._collect_user_tasks(process_dict) # id → UserTask dict
gateways = self._collect_gateways(process_dict) # id → gateway dict
start_events = self._collect_start_events(process_dict)
end_events = self._collect_end_events(process_dict)
# Step 1: StartEvent 後第一個 UserTask 必 can_start
violations.extend(self._check_start_stage(start_events, sequence_flows, user_tasks))
# Step 2: 所有 stage_object_code 必合法(同時把 unknown 標出來)
violations.extend(self._check_known_stage_codes(user_tasks))
# Step 3: 每條 forward UserTask→UserTask 的 successor 檢查(reverse skip)
violations.extend(self._check_forward_successors(sequence_flows, user_tasks, gateways))
# Step 4: 連到 EndEvent 的 UserTask 必 can_end
violations.extend(self._check_end_stage(end_events, sequence_flows, user_tasks))
# Step 5: ExclusiveGateway 至少一條 outgoing 有 condition
violations.extend(self._check_gateway_conditions(gateways, sequence_flows))
# Step 6: Reachability — 走 forward edge only
violations.extend(self._check_reachability(
start_events, end_events, sequence_flows, user_tasks, gateways
))
return violations
# ── helpers ──────────────────────────────────────────────────────────
def _to_list(self, x) -> list:
if x is None:
return []
return x if isinstance(x, list) else [x]
def _collect_sequence_flows(self, process_dict: dict) -> List[dict]:
flows = self._to_list(process_dict.get("bpmn:sequenceFlow"))
return flows
def _collect_user_tasks(self, process_dict: dict) -> Dict[str, dict]:
tasks = self._to_list(process_dict.get("bpmn:userTask"))
return {t["@id"]: t for t in tasks}
def _collect_gateways(self, process_dict: dict) -> Dict[str, dict]:
gws = self._to_list(process_dict.get("bpmn:exclusiveGateway"))
return {g["@id"]: g for g in gws}
def _collect_start_events(self, process_dict: dict) -> List[dict]:
return self._to_list(process_dict.get("bpmn:startEvent"))
def _collect_end_events(self, process_dict: dict) -> List[dict]:
return self._to_list(process_dict.get("bpmn:endEvent"))
def _get_stage_code(self, user_task: dict) -> Optional[str]:
ext = user_task.get("bpmn:extensionElements") or {}
props = ext.get("camunda:properties") or {}
plist = self._to_list(props.get("camunda:property"))
for p in plist:
if p.get("@name") == "stage_object_code":
return p.get("@value")
return None
def _is_reverse_flow(self, flow: dict) -> bool:
# Primary:extension property
ext = flow.get("bpmn:extensionElements") or {}
props = ext.get("camunda:properties") or {}
plist = self._to_list(props.get("camunda:property"))
for p in plist:
if p.get("@name") == "reverse" and str(p.get("@value")).lower() == "true":
return True
# Fallback:condition regex
cond = flow.get("bpmn:conditionExpression")
if isinstance(cond, dict):
cond_text = cond.get("#text", "")
elif isinstance(cond, str):
cond_text = cond
else:
cond_text = ""
return is_reverse_condition(cond_text)
def _resolve_user_task_or_gateway(self, target_ref: str,
user_tasks: Dict[str, dict],
gateways: Dict[str, dict]) -> Optional[dict]:
return user_tasks.get(target_ref) or gateways.get(target_ref)
# ── checks ──────────────────────────────────────────────────────────
def _check_start_stage(self, start_events, sequence_flows, user_tasks):
out: List[ValidationViolation] = []
for se in start_events:
outgoing_ids = self._to_list(se.get("bpmn:outgoing"))
for fid in outgoing_ids:
flow = next((f for f in sequence_flows if f.get("@id") == fid), None)
if flow is None:
continue
target_id = flow.get("@targetRef")
ut = user_tasks.get(target_id)
if ut is None:
out.append(ValidationViolation(
rule="start_not_to_user_task",
reason_i18n_key="flow_engine.validator.start_not_to_user_task",
context={"target_ref": target_id},
))
continue
code = self._get_stage_code(ut)
rule = self._rules.get(code or "")
if rule is None or not rule.can_start:
out.append(ValidationViolation(
rule="start_stage_not_allowed",
reason_i18n_key="flow_engine.validator.start_stage_not_allowed",
context={"stage": code or "<unknown>"},
))
return out
def _check_known_stage_codes(self, user_tasks):
out: List[ValidationViolation] = []
for tid, ut in user_tasks.items():
code = self._get_stage_code(ut)
if code is None:
out.append(ValidationViolation(
rule="user_task_missing_stage_code",
reason_i18n_key="flow_engine.validator.user_task_missing_stage_code",
context={"user_task_id": tid},
))
elif code not in self._rules:
out.append(ValidationViolation(
rule="unknown_stage_code",
reason_i18n_key="flow_engine.validator.unknown_stage_code",
context={"code": code, "user_task_id": tid},
))
return out
def _check_forward_successors(self, sequence_flows, user_tasks, gateways):
out: List[ValidationViolation] = []
for flow in sequence_flows:
if self._is_reverse_flow(flow):
continue
source_id = flow.get("@sourceRef")
target_id = flow.get("@targetRef")
source_ut = user_tasks.get(source_id)
if source_ut is None:
continue # source 是 Gateway / StartEvent,不在此檢查
target_ut = user_tasks.get(target_id)
if target_ut is None:
continue # target 是 Gateway / EndEvent,跳過(end 由 check_end_stage 處理)
src_code = self._get_stage_code(source_ut)
tgt_code = self._get_stage_code(target_ut)
src_rule = self._rules.get(src_code or "")
if src_rule is None:
continue
if tgt_code not in src_rule.allowed_successors:
out.append(ValidationViolation(
rule="successor_not_allowed",
reason_i18n_key="flow_engine.validator.successor_not_allowed",
context={
"source_stage": src_code or "",
"target_stage": tgt_code or "",
"sequence_flow_id": flow.get("@id"),
},
))
return out
def _check_end_stage(self, end_events, sequence_flows, user_tasks):
out: List[ValidationViolation] = []
end_ids = {e["@id"] for e in end_events}
for flow in sequence_flows:
target_id = flow.get("@targetRef")
if target_id not in end_ids:
continue
source_id = flow.get("@sourceRef")
source_ut = user_tasks.get(source_id)
if source_ut is None:
continue
code = self._get_stage_code(source_ut)
rule = self._rules.get(code or "")
if rule is None or not rule.can_end:
out.append(ValidationViolation(
rule="end_stage_not_allowed",
reason_i18n_key="flow_engine.validator.end_stage_not_allowed",
context={"stage": code or "<unknown>"},
))
return out
def _check_gateway_conditions(self, gateways, sequence_flows):
out: List[ValidationViolation] = []
for gid, gw in gateways.items():
outgoing_ids = self._to_list(gw.get("bpmn:outgoing"))
has_condition = False
for fid in outgoing_ids:
flow = next((f for f in sequence_flows if f.get("@id") == fid), None)
if flow is None:
continue
cond = flow.get("bpmn:conditionExpression")
if cond:
has_condition = True
break
if not has_condition:
out.append(ValidationViolation(
rule="gateway_missing_condition",
reason_i18n_key="flow_engine.validator.gateway_missing_condition",
context={"gateway_id": gid},
))
return out
def _check_reachability(self, start_events, end_events,
sequence_flows, user_tasks, gateways):
"""Forward-only BFS:reverse edge 視為不存在。"""
out: List[ValidationViolation] = []
if not start_events:
return out
# Build forward adjacency(skip reverse)
adj: Dict[str, List[str]] = {}
for flow in sequence_flows:
if self._is_reverse_flow(flow):
continue
src = flow.get("@sourceRef")
tgt = flow.get("@targetRef")
adj.setdefault(src, []).append(tgt)
start_id = start_events[0]["@id"]
reachable: Set[str] = set()
stack = [start_id]
while stack:
node = stack.pop()
if node in reachable:
continue
reachable.add(node)
for nxt in adj.get(node, []):
stack.append(nxt)
# Unreachable UserTask
for tid in user_tasks.keys():
if tid not in reachable:
out.append(ValidationViolation(
rule="unreachable_node",
reason_i18n_key="flow_engine.validator.unreachable_node",
context={"node_id": tid},
))
# 至少有一條 forward path 到 EndEvent
end_ids = {e["@id"] for e in end_events}
if not (end_ids & reachable):
out.append(ValidationViolation(
rule="no_end_path",
reason_i18n_key="flow_engine.validator.no_end_path",
))
return outpytest tests/test_bpmn_topology_validator.py -vExpected:
test_builtin_internal_check_passestest_builtin_self_assessment_passestest_builtin_full_audit_passes_after_reverse_patch — fail with successor_not_allowed (poam→audit) — 這是預期,M4 patch 後才會過test_start_stage_not_allowedtest_unknown_stage_codetest_no_end_path_pure_reverse_cycle如果 test_builtin_full_audit_passes_after_reverse_patch 以外的任何 test fail → 看實作
git add app/flow_engine/util/bpmn_topology_validator.py tests/test_bpmn_topology_validator.py
git commit -m "$(cat <<'EOF'
feat(spec4-M3): BpmnTopologyValidator 純函式 + 13 條 unit test
對應 spec 4 design.md §7:
- StageRules / ValidationViolation dataclass
- is_reverse_condition strict anchored regex(design §7.3 false positive 防範)
- 7 條檢查:start_stage / known_codes / forward_successors / end_stage /
gateway_conditions / reachability (forward only) / no_end_path
- Builtin 範本 3 個 pass case(full-audit 待 M4 patch reverse)
builtin-full-audit test 預期 fail until M4。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"builtin-full-audit-with-reviewbuiltin-full-audit BPMN seed fileFiles:
scripts/sql/seeds/bpmn/builtin-full-audit.bpmn找到:
<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>改為:
<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit">
<bpmn:extensionElements>
<camunda:properties>
<camunda:property name="reverse" value="true"/>
</camunda:properties>
</bpmn:extensionElements>
</bpmn:sequenceFlow>pytest tests/test_bpmn_topology_validator.py::test_builtin_full_audit_passes_after_reverse_patch -vExpected: ✅ PASS
builtin-full-audit-with-review BPMN seed fileFiles:
scripts/sql/seeds/bpmn/builtin-full-audit-with-review.bpmn<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:camunda="http://camunda.org/schema/1.0/bpmn"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
id="Definitions_builtin_full_audit_with_review"
targetNamespace="http://bpmn.io/schema/bpmn">
<bpmn:process id="Process_builtin_full_audit_with_review" name="完整稽核流程(含審核)" isExecutable="true">
<bpmn:startEvent id="StartEvent_1" name="開始">
<bpmn:outgoing>Flow_start_planning</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:userTask id="UserTask_planning" name="規劃">
<bpmn:extensionElements>
<camunda:properties>
<camunda:property name="stage_object_code" value="planning"/>
<camunda:property name="main_role" value="manager"/>
</camunda:properties>
</bpmn:extensionElements>
<bpmn:incoming>Flow_start_planning</bpmn:incoming>
<bpmn:outgoing>Flow_planning_task_execution</bpmn:outgoing>
</bpmn:userTask>
<bpmn:userTask id="UserTask_task_execution" name="執行任務">
<bpmn:extensionElements>
<camunda:properties>
<camunda:property name="stage_object_code" value="task_execution"/>
<camunda:property name="main_role" value="manager"/>
</camunda:properties>
</bpmn:extensionElements>
<bpmn:incoming>Flow_planning_task_execution</bpmn:incoming>
<bpmn:incoming>Flow_review_reject</bpmn:incoming>
<bpmn:outgoing>Flow_task_execution_review</bpmn:outgoing>
</bpmn:userTask>
<bpmn:userTask id="UserTask_review" name="審核">
<bpmn:extensionElements>
<camunda:properties>
<camunda:property name="stage_object_code" value="review"/>
<camunda:property name="main_role" value="reviewer"/>
</camunda:properties>
</bpmn:extensionElements>
<bpmn:incoming>Flow_task_execution_review</bpmn:incoming>
<bpmn:outgoing>Flow_review_gateway</bpmn:outgoing>
</bpmn:userTask>
<bpmn:exclusiveGateway id="Gateway_review_decision" name="審核決議" default="Flow_review_forward">
<bpmn:incoming>Flow_review_gateway</bpmn:incoming>
<bpmn:outgoing>Flow_review_forward</bpmn:outgoing>
<bpmn:outgoing>Flow_review_reject</bpmn:outgoing>
</bpmn:exclusiveGateway>
<bpmn:userTask id="UserTask_audit" name="稽核">
<bpmn:extensionElements>
<camunda:properties>
<camunda:property name="stage_object_code" value="audit"/>
<camunda:property name="main_role" value="auditor"/>
</camunda:properties>
</bpmn:extensionElements>
<bpmn:incoming>Flow_review_forward</bpmn:incoming>
<bpmn:incoming>Flow_poam_audit</bpmn:incoming>
<bpmn:outgoing>Flow_audit_gateway</bpmn:outgoing>
</bpmn:userTask>
<bpmn:exclusiveGateway id="Gateway_has_findings" name="是否有缺失" default="Flow_gateway_end">
<bpmn:incoming>Flow_audit_gateway</bpmn:incoming>
<bpmn:outgoing>Flow_gateway_poam</bpmn:outgoing>
<bpmn:outgoing>Flow_gateway_end</bpmn:outgoing>
</bpmn:exclusiveGateway>
<bpmn:userTask id="UserTask_poam" name="缺失改善">
<bpmn:extensionElements>
<camunda:properties>
<camunda:property name="stage_object_code" value="poam"/>
<camunda:property name="main_role" value="manager"/>
</camunda:properties>
</bpmn:extensionElements>
<bpmn:incoming>Flow_gateway_poam</bpmn:incoming>
<bpmn:outgoing>Flow_poam_audit</bpmn:outgoing>
</bpmn:userTask>
<bpmn:endEvent id="EndEvent_1" name="結案">
<bpmn:incoming>Flow_gateway_end</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_start_planning" sourceRef="StartEvent_1" targetRef="UserTask_planning"/>
<bpmn:sequenceFlow id="Flow_planning_task_execution" sourceRef="UserTask_planning" targetRef="UserTask_task_execution"/>
<bpmn:sequenceFlow id="Flow_task_execution_review" sourceRef="UserTask_task_execution" targetRef="UserTask_review"/>
<bpmn:sequenceFlow id="Flow_review_gateway" sourceRef="UserTask_review" targetRef="Gateway_review_decision"/>
<bpmn:sequenceFlow id="Flow_review_forward" name="通過" sourceRef="Gateway_review_decision" targetRef="UserTask_audit"/>
<bpmn:sequenceFlow id="Flow_review_reject" name="退回" sourceRef="Gateway_review_decision" targetRef="UserTask_task_execution">
<bpmn:extensionElements>
<camunda:properties>
<camunda:property name="reverse" value="true"/>
</camunda:properties>
</bpmn:extensionElements>
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${decision == 'reject'}</bpmn:conditionExpression>
</bpmn:sequenceFlow>
<bpmn:sequenceFlow id="Flow_audit_gateway" sourceRef="UserTask_audit" targetRef="Gateway_has_findings"/>
<bpmn:sequenceFlow id="Flow_gateway_poam" name="有缺失" sourceRef="Gateway_has_findings" targetRef="UserTask_poam">
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${has_findings == true}</bpmn:conditionExpression>
</bpmn:sequenceFlow>
<bpmn:sequenceFlow id="Flow_gateway_end" name="無缺失" sourceRef="Gateway_has_findings" targetRef="EndEvent_1"/>
<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit">
<bpmn:extensionElements>
<camunda:properties>
<camunda:property name="reverse" value="true"/>
</camunda:properties>
</bpmn:extensionElements>
</bpmn:sequenceFlow>
</bpmn:process>
</bpmn:definitions>def test_builtin_full_audit_with_review_passes(default_rules):
xml = _read_builtin_xml("builtin-full-audit-with-review")
violations = BpmnTopologyValidator(default_rules).validate(xml)
assert violations == [], f"unexpected violations: {violations}"pytest tests/test_bpmn_topology_validator.py::test_builtin_full_audit_with_review_passes -vExpected: ✅ PASS
Files:
scripts/sql/2026-05-14-spec4-builtin-flow-templates.sql-- Date: 2026-05-14
-- Spec 4 — builtin BPMN reverse property patch + 新增 builtin-full-audit-with-review
-- 1. Patch builtin-full-audit 既有 Flow_poam_audit 加 reverse extension property (2026-05-14)
UPDATE compliance.flow_templates
SET bpmn_xml = REPLACE(
bpmn_xml,
'<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>',
'<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"><bpmn:extensionElements><camunda:properties><camunda:property name="reverse" value="true"/></camunda:properties></bpmn:extensionElements></bpmn:sequenceFlow>'
),
updated_user = 'system',
updated_at = NOW()
WHERE is_builtin = TRUE
AND name = '完整稽核流程'
AND bpmn_xml LIKE '%<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>%';
-- 2. Patch 自訂範本 duplicate 自 builtin-full-audit 的 — design §7.5 backfill (2026-05-14)
-- 條件:is_builtin=FALSE 且 XML 含未 patch 的 Flow_poam_audit
UPDATE compliance.flow_templates
SET bpmn_xml = REPLACE(
bpmn_xml,
'<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>',
'<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"><bpmn:extensionElements><camunda:properties><camunda:property name="reverse" value="true"/></camunda:properties></bpmn:extensionElements></bpmn:sequenceFlow>'
),
updated_user = 'system',
updated_at = NOW()
WHERE is_builtin = FALSE
AND is_active = TRUE
AND bpmn_xml LIKE '%<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>%';
-- 3. Seed 新 builtin 範本 builtin-full-audit-with-review (2026-05-14)
-- BPMN XML 用 PG dollar-quoting
INSERT INTO compliance.flow_templates (tenant_id, name, description, is_builtin, bpmn_xml, created_user, updated_user)
SELECT NULL, '完整稽核流程(含審核)',
'規劃 → 執行 → 審核 → 稽核 → 缺失改善 → 結案;reviewer 過審或退回流程', TRUE,
$xml$<?xml version="1.0" encoding="UTF-8"?>
<!-- [貼上 Task M4.2 的完整 XML 內容] -->
$xml$,
'system', 'system'
WHERE NOT EXISTS (
SELECT 1 FROM compliance.flow_templates
WHERE is_builtin = TRUE AND name = '完整稽核流程(含審核)'
);
-- 4. 驗證 SQL(manual run)
-- SELECT id, name, is_builtin, LENGTH(bpmn_xml) AS xml_size FROM compliance.flow_templates ORDER BY id;
-- SELECT id, name FROM compliance.flow_templates
-- WHERE bpmn_xml LIKE '%<bpmn:sequenceFlow id="Flow_poam_audit" sourceRef="UserTask_poam" targetRef="UserTask_audit"/>%';
-- (第二條應該回 0 列)python3 - <<'PYEOF'
xml = open("scripts/sql/seeds/bpmn/builtin-full-audit-with-review.bpmn").read()
sql = open("scripts/sql/2026-05-14-spec4-builtin-flow-templates.sql").read()
# 用 plain string replace,避開 re.sub 對 \1 \g<...> 等 regex metachar 的解讀
sql_new = sql.replace(
"<!-- [貼上 Task M4.2 的完整 XML 內容] -->",
xml,
)
open("scripts/sql/2026-05-14-spec4-builtin-flow-templates.sql", "w").write(sql_new)
print("DONE")
PYEOFpsql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c \
"SELECT id, tenant_id, name FROM compliance.flow_templates
WHERE is_builtin = FALSE AND is_active = TRUE
AND bpmn_xml LIKE '%<bpmn:sequenceFlow id=\"Flow_poam_audit\" sourceRef=\"UserTask_poam\" targetRef=\"UserTask_audit\"/>%';"Expected: 列出將被 patch 的自訂範本 row(可能是 0 列 — dev 環境通常無)。若有 row → 確認 user 認可前不要 commit migration。
psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
-f scripts/sql/2026-05-14-spec4-builtin-flow-templates.sqlExpected: 1 UPDATE 1(builtin patch)+ N UPDATE N(自訂 backfill 數)+ 1 INSERT 0 1(新 builtin)
psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c \
"SELECT id, name, is_builtin, LENGTH(bpmn_xml) AS xml_size FROM compliance.flow_templates ORDER BY id;"Expected: 至少 4 列(既有 3 builtin + 新 builtin),LENGTH 都 > 1000。
git add scripts/sql/seeds/bpmn/builtin-full-audit.bpmn \
scripts/sql/seeds/bpmn/builtin-full-audit-with-review.bpmn \
scripts/sql/2026-05-14-spec4-builtin-flow-templates.sql \
tests/test_bpmn_topology_validator.py
git commit -m "$(cat <<'EOF'
feat(spec4-M4): builtin-full-audit patch reverse + 新 builtin-full-audit-with-review
對應 spec 4 design.md §7.5 + §9:
- Patch builtin-full-audit BPMN Flow_poam_audit 加 reverse extension property
- 自訂範本 backfill:scan 並 patch duplicate 自 builtin-full-audit 的範本
- 新增 builtin-full-audit-with-review(規劃→執行→審核→稽核→改善→結案)
含 Gateway_review_decision 兩條 outgoing:forward(approve) / reverse(reject)
- M3 test_builtin_full_audit_passes_after_reverse_patch / new with_review 都過
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Files:
common/code/grc_error_code.py GRC_FLOW_TEMPLATE_TOPOLOGY_INVALID = ("範本流程結構不合法", "GRC_400060")
GRC_STAGE_REVIEW_REJECT_REQUIRES_COMMENT = ("退回審核必須附留言", "GRC_400061")
GRC_STAGE_REVIEW_DECISION_INVALID = ("decision 值必須為 approve 或 reject", "GRC_400062")FlowTemplateAppService 整合 validatorFiles:
app/flow_engine/service/flow_template_app_service.pyfrom app.flow_engine.util.bpmn_topology_validator import (
BpmnTopologyValidator,
StageRules,
ValidationViolation,
)
from domain.flow_engine.entity.stage_object_query_entity import StageObjectQueryEntitydef __init__(
self,
flow_template_domain_service: FlowTemplateDomainService,
stage_object_domain_service, # 新增
user_service,
):
self._domain = flow_template_domain_service
self._stage_object_domain_service = stage_object_domain_service # 新增
self._user_service = user_servicedef _load_stage_rules(self) -> dict:
entities = self._stage_object_domain_service.find_all(
StageObjectQueryEntity(is_active=True)
)
return {
e.code: StageRules(
can_start=e.can_start,
can_end=e.can_end,
allowed_predecessors=e.allowed_predecessors or [],
allowed_successors=e.allowed_successors or [],
)
for e in entities
}
def _validate_bpmn_topology(self, xml: str) -> None:
rules = self._load_stage_rules()
violations = BpmnTopologyValidator(rules).validate(xml)
if violations:
err = BadRequestError(GrcErrorCode.GRC_FLOW_TEMPLATE_TOPOLOGY_INVALID)
err.context = {
"violations": [
{
"rule": v.rule,
"reason_i18n_key": v.reason_i18n_key,
"context": v.context,
}
for v in violations
]
}
raise err@transaction
def create(self, name: str, description: Optional[str], bpmn_xml: str) -> FlowTemplateDto:
self._validate_bpmn(bpmn_xml)
self._validate_bpmn_topology(bpmn_xml) # 新增
...同樣在 update method 對應位置加。
@transaction
def validate(self, bpmn_xml: str) -> dict:
"""Endpoint /flow-engine/flow-templates/validate 用:
純驗證不存檔,回 violations 列表(無錯就空)。
"""
self._validate_bpmn(bpmn_xml) # 基本格式檢查仍 raise 400
rules = self._load_stage_rules()
violations = BpmnTopologyValidator(rules).validate(bpmn_xml)
return {
"valid": len(violations) == 0,
"violations": [
{"rule": v.rule, "reason_i18n_key": v.reason_i18n_key, "context": v.context}
for v in violations
],
}Files:
di_containers/flow_engine/flow_template_containers.pygrep -n "FlowTemplateAppService\|stage_object_domain_service" di_containers/flow_engine/flow_template_containers.pyExpected: FlowTemplateAppService Factory declared inside this file; stage_object_domain_service 也已 declared(spec 1 ship 時就有)— 因此 wiring 只需把它注入 Factory,不需新建 domain service。
找到既有:
flow_template_app_service = providers.Factory(
FlowTemplateAppService,
flow_template_domain_service=flow_template_domain_service,
user_service=...,
)改為:
flow_template_app_service = providers.Factory(
FlowTemplateAppService,
flow_template_domain_service=flow_template_domain_service,
stage_object_domain_service=stage_object_domain_service, # 新增
user_service=...,
)lsof -ti :8000 | xargs kill -9 2>/dev/null; sleep 1
nohup python main_socketio.py > /tmp/be.log 2>&1 &
sleep 3 && tail -20 log/app.logExpected: 無 DI / import 錯誤。
TOKEN=$(curl -s -X POST http://localhost:8000/login \
-H "Content-Type: application/json" \
-d '{"login_name":"blsadmin","password":"Billows@123!"}' | python -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")
curl -s -X POST http://localhost:8000/flow-engine/flow-templates \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "test-invalid",
"description": "should fail",
"bpmn_xml": "<?xml version=\"1.0\"?><bpmn:definitions xmlns:bpmn=\"http://www.omg.org/spec/BPMN/20100524/MODEL\" xmlns:camunda=\"http://camunda.org/schema/1.0/bpmn\"><bpmn:process id=\"P\" isExecutable=\"true\"><bpmn:startEvent id=\"S\"><bpmn:outgoing>F1</bpmn:outgoing></bpmn:startEvent><bpmn:userTask id=\"UT_p\"><bpmn:extensionElements><camunda:properties><camunda:property name=\"stage_object_code\" value=\"poam\"/></camunda:properties></bpmn:extensionElements><bpmn:incoming>F1</bpmn:incoming><bpmn:outgoing>F2</bpmn:outgoing></bpmn:userTask><bpmn:endEvent id=\"E\"><bpmn:incoming>F2</bpmn:incoming></bpmn:endEvent><bpmn:sequenceFlow id=\"F1\" sourceRef=\"S\" targetRef=\"UT_p\"/><bpmn:sequenceFlow id=\"F2\" sourceRef=\"UT_p\" targetRef=\"E\"/></bpmn:process></bpmn:definitions>"
}'Expected: HTTP 400,envelope {"code": 0, "msg": "...", "data": {"violations": [{"rule": "start_stage_not_allowed", ...}]}}
git add common/code/grc_error_code.py \
app/flow_engine/service/flow_template_app_service.py \
di_containers/flow_engine/flow_template_containers.py
git commit -m "$(cat <<'EOF'
feat(spec4-M5): FlowTemplateAppService 整合 BpmnTopologyValidator
對應 spec 4 design.md §6.3 + §7 + §11:
- 加 3 個 error code(GRC_400060/061/062)
- _validate_bpmn_topology + _load_stage_rules 接 stage_objects 規則表
- create / update 加 topology validator pass
- 新 public method validate() 給 /flow-templates/validate endpoint 用(M6)
- DI wiring:FlowTemplateAppService 加 stage_object_domain_service 依賴
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"POST /flow-engine/flow-templates/validate endpointFiles:
api/flow_engine/serializers/flow_template.pyclass FlowTemplateValidateRequestSchema(Schema):
bpmn_xml = fields.Str(required=True)
class FlowTemplateValidationViolationSchema(Schema):
rule = fields.Str(required=True)
reason_i18n_key = fields.Str(allow_none=True)
context = fields.Dict(load_default=dict)
class FlowTemplateValidateResponseSchema(Schema):
valid = fields.Bool(required=True)
violations = fields.List(fields.Nested(FlowTemplateValidationViolationSchema), load_default=list)Files:
api/flow_engine/routes/flow_template_route.pyapi/flow_engine/__init__.pyclass FlowTemplateValidateResource(Resource):
method_decorators = [jwt_required()]
@inject
def post(
self,
flow_template_app_service: FlowTemplateAppService = Provide[
Containers.flow_engine_container.flow_template_app_service
],
):
body = FlowTemplateValidateRequestSchema().load(request.get_json(silent=True) or {})
result = flow_template_app_service.validate(bpmn_xml=body["bpmn_xml"])
return return_response(True, FlowTemplateValidateResponseSchema().dump(result))api.add_resource(FlowTemplateValidateResource, "/flow-engine/flow-templates/validate")lsof -ti :8000 | xargs kill -9 2>/dev/null; sleep 1
nohup python main_socketio.py > /tmp/be.log 2>&1 &
sleep 3
# 應該 200 valid=true
TOKEN=...(同上)
curl -s -X POST http://localhost:8000/flow-engine/flow-templates/validate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"bpmn_xml\": $(python -c "import json;print(json.dumps(open('scripts/sql/seeds/bpmn/builtin-internal-check.bpmn').read()))")}"Expected: {"code": 1, "data": {"valid": true, "violations": []}}
curl -s -X POST http://localhost:8000/flow-engine/flow-templates/validate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"bpmn_xml": "<不合法的 XML>"}'Expected: HTTP 400 GRC_400040(既有 _validate_bpmn 擋下)。
git add api/flow_engine/serializers/flow_template.py \
api/flow_engine/routes/flow_template_route.py \
api/flow_engine/__init__.py
git commit -m "$(cat <<'EOF'
feat(spec4-M6): POST /flow-engine/flow-templates/validate endpoint
對應 spec 4 design.md §6.3:
- 純驗證 endpoint,FE editor inline warning 拖 sequenceFlow 時 debounce call
- 不存檔;回 {valid, violations:[]}
- 沿用 FlowTemplateAppService.validate() (M5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"StageAdvanceRequestSchema 加 decision / comment + ReviewDecisionHandlerFiles:
api/flow_engine/serializers/stage_advance.pyfrom marshmallow import Schema, fields, validate, validates_schema, ValidationError
class StageAdvanceRequestSchema(Schema):
force = fields.Bool(load_default=False)
ctx = fields.Dict(load_default=dict)
# v1 新增
decision = fields.Str(
load_default=None,
validate=validate.OneOf(["approve", "reject"]),
)
comment = fields.Str(load_default=None)
@validates_schema
def _validate_reject_requires_comment(self, data, **kwargs):
if data.get("decision") == "reject" and not (data.get("comment") or "").strip():
raise ValidationError(
{"comment": ["reject 必須附留言(GRC_400061)"]},
field_name="comment",
)Files:
app/grc/service/oscal_stage_handlers.pyclass ReviewDecisionHandler(IStageCompletionHandler):
"""review stage 推進 — dispatch decision;不動 AP.status。
對應 spec 4 design.md §8.1:
- decision=approve:BPMN 走 default forward;AP.status 由下個 stage 接管
- decision=reject:BPMN 走 reject sequenceFlow,回上游 UserTask;AP.status 不動
- 無業務 precondition(公開議題 2 — reviewer 自由判斷)
"""
@property
def key(self) -> str:
return "review_decision"
def execute(
self,
ap_uid: str,
project_uid: str,
user_id: int,
curr_user: str,
ctx: dict,
) -> dict:
decision = (ctx or {}).get("decision") or "approve"
return {"decision": decision, "warning": None}Files:
di_containers/grc/grc_containers.pyfrom app.grc.service.oscal_stage_handlers import (
PlanningOnCompleteHandler,
TaskExecutionOnCompleteHandler,
AuditOnCompleteHandler,
PoamOnCompleteHandler,
TerminalCloseHandler,
ReviewDecisionHandler, # 新增
)review_decision_handler = providers.Factory(ReviewDecisionHandler)def register_stage_hooks_to_registry(grc_container, flow_template_container) -> None:
registry = flow_template_container.stage_registry()
registry.register_handler(grc_container.planning_on_complete_handler())
registry.register_handler(grc_container.task_execution_on_complete_handler())
registry.register_handler(grc_container.audit_on_complete_handler())
registry.register_handler(grc_container.poam_on_complete_handler())
registry.register_handler(grc_container.terminal_close_handler())
registry.register_handler(grc_container.review_decision_handler()) # 新增
...git add api/flow_engine/serializers/stage_advance.py \
app/grc/service/oscal_stage_handlers.py \
di_containers/grc/grc_containers.py
git commit -m "$(cat <<'EOF'
feat(spec4-M7): StageAdvance schema + ReviewDecisionHandler
對應 spec 4 design.md §6.1 + §8.1:
- StageAdvanceRequestSchema 加 decision (approve|reject) + comment 欄位
- @validates_schema:decision=reject 必填 comment(GRC_400061)
- ReviewDecisionHandler:純 dispatch decision,不動 AP.status
- 註冊到 stage_registry
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"StageAdvanceService 接 decision/comment + JobComment 整合 + condition_param review 分支_build_condition_param 加 review 分支Files:
app/flow_engine/service/stage_advance_service.pydef _build_condition_param(self, stage_code: str, handler_result) -> Optional[dict]:
if stage_code == "audit": # 既有
if not isinstance(handler_result, dict):
return None
new_status = handler_result.get("status")
return {"has_findings": new_status == "remediation"}
if stage_code == "review": # v1 新增
if isinstance(handler_result, dict):
decision = handler_result.get("decision", "approve")
return {"decision": decision}
return {"decision": "approve"}
return Noneadvance_stage 接 decision/comment + 非 review stage ignore decision warningFiles:
app/flow_engine/service/stage_advance_service.py@transaction
def advance_stage(
self,
project_uid: str,
ap_uid: str,
user_id: int,
curr_user: str,
force: bool = False,
decision: Optional[str] = None, # v1 新增
comment: Optional[str] = None, # v1 新增
ctx: Optional[dict] = None,
) -> dict:
ctx = dict(ctx or {})
ctx.setdefault("force", force)
if decision:
ctx["decision"] = decision
... stage_object = self._stage_object_domain_service.get_one(
StageObjectQueryEntity(code=stage_code)
)
if stage_object is None:
raise NotFound(GrcErrorCode.GRC_STAGE_OBJECT_NOT_FOUND)
# v1 — design §6.1:非 review stage 送 decision 忽略 + log warning
if decision is not None and stage_object.code != "review":
logger.warning(
"decision payload ignored for non-review stage code=%s",
stage_object.code,
)
ctx.pop("decision", None)Files:
app/flow_engine/service/stage_advance_service.pydef __init__(
self,
...既有 8 個 deps...,
job_comment_service, # v1 新增
):
...
self._job_comment_service = job_comment_service接在 self._workflow_execution_service.complete_main_workflow_job(...) 之後、return 之前:
# v1 — design §8.2:寫 GRC JobComment(reuse 既有 service)
# CRITICAL:job_uid 是 JobExecution.uid (UUID),不是 BPMN element id;
# complete_main_workflow_job 後 curr_job_execution 已 COMPLETED,
# 改用 template_job_id filter(不限 status)拿 uid
if comment and comment.strip():
try:
job_exec = self._job_execution_domain_service.get_job_execution(
JobExecutionQueryEntity(
workflow_execution_id=wf_ctx["workflow_execution_id"],
template_job_id=curr_job_template.id,
)
)
if job_exec is None:
logger.warning(
"JobExecution not found for template_job_id=%s — skip JobComment write",
curr_job_template.id,
)
else:
self._job_comment_service.add_comment(
job_uid=str(job_exec.uid),
content=comment.strip(),
user_id=user_id,
author_nickname=ctx.get("user_nickname") or curr_user,
)
except Exception:
logger.warning("write GRC JobComment failed (non-blocking)", exc_info=True)Files:
api/flow_engine/routes/stage_advance_route.py既有 route body(line 50–69 附近)長這樣:
def post(self, project_uid, ap_uid, service=...):
body = StageAdvanceRequestSchema().load(request.get_json(silent=True) or {})
user_ctx = get_user_context()
ctx = dict(body.get("ctx") or {})
ctx.setdefault("user_nickname", user_ctx.nickname)
result = service.advance_stage(
project_uid=project_uid,
ap_uid=ap_uid,
user_id=user_ctx.id,
curr_user=user_ctx.login_name,
force=bool(body.get("force", False)),
ctx=ctx,
)
return return_response(True, StageAdvanceResponseSchema().dump(result))只需在 service.advance_stage(...) 加兩個 kwargs:
result = service.advance_stage(
project_uid=project_uid,
ap_uid=ap_uid,
user_id=user_ctx.id,
curr_user=user_ctx.login_name,
force=bool(body.get("force", False)),
decision=body.get("decision"), # 新增
comment=body.get("comment"), # 新增
ctx=ctx,
)不動 ctx build 邏輯 — 既有 user_nickname 來自 user_ctx.nickname,service M8.3 寫 JobComment 已會從 ctx.get("user_nickname") 取,wiring 一致。
Files:
di_containers/grc/grc_containers.pystage_advance_service = providers.Factory(
StageAdvanceService,
stage_registry=flow_template_container.stage_registry,
stage_object_domain_service=flow_template_container.stage_object_domain_service,
assessment_plan_extension_domain_service=assessment_plan_extension_domain_service,
workflow_execution_service=workflow_execution_container.workflow_execution_service,
workflow_execution_domain_service=workflow_execution_container.workflow_execution_domain_service,
job_execution_domain_service=workflow_execution_container.job_execution_domain_service,
project_domain_service=project_container.project_domain_service,
project_participant_domain_service=project_participant_container.project_participant_domain_service,
job_comment_service=job_comment_service, # 新增
)lsof -ti :8000 | xargs kill -9 2>/dev/null; sleep 1
nohup python main_socketio.py > /tmp/be.log 2>&1 &
sleep 3 && tail -20 log/app.logExpected: 無 DI 錯誤。
依 design §12.2 流程 smoke。詳細放 M11 全範圍 smoke。
git add app/flow_engine/service/stage_advance_service.py \
api/flow_engine/routes/stage_advance_route.py \
di_containers/grc/grc_containers.py
git commit -m "$(cat <<'EOF'
feat(spec4-M8): StageAdvanceService 接 decision/comment + JobComment 整合
對應 spec 4 design.md §6.1 + §8.2 + §8.3:
- advance_stage 接受 decision/comment optional 參數
- 非 review stage 送 decision 忽略 + log warning(design §6.1)
- _build_condition_param 加 review 分支:{"decision": "approve"|"reject"}
- complete_main_workflow_job 後寫 GRC JobComment(reuse JobCommentService)
CRITICAL fix:先查 JobExecution.uid (UUID) 再傳,不能直接用 BPMN element id
- DI wiring:StageAdvanceService 加 job_comment_service 依賴
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Files:
tests/test_stage_advance_v4.py"""Spec 4 — StageAdvanceService decision/comment integration test."""
import pytest
def test_advance_with_approve_decision_no_comment(app_client, headers, seed_review_ap):
"""approve + 無 comment → 200,BPMN 走 forward"""
resp = app_client.post(
f"/project/{seed_review_ap['project_uid']}/ap/{seed_review_ap['ap_uid']}/stage/advance",
json={"decision": "approve"},
headers=headers,
)
assert resp.status_code == 200
body = resp.get_json()
assert body["status"] is True
assert body["data"]["advanced"] is True
def test_advance_with_reject_requires_comment(app_client, headers, seed_review_ap):
"""reject + 無 comment → 400"""
resp = app_client.post(
f"/project/{seed_review_ap['project_uid']}/ap/{seed_review_ap['ap_uid']}/stage/advance",
json={"decision": "reject"},
headers=headers,
)
assert resp.status_code == 400
def test_advance_with_reject_and_comment(app_client, headers, seed_review_ap):
"""reject + comment → 200,BPMN 回 task_execution"""
resp = app_client.post(
f"/project/{seed_review_ap['project_uid']}/ap/{seed_review_ap['ap_uid']}/stage/advance",
json={"decision": "reject", "comment": "需要重新整理證據"},
headers=headers,
)
assert resp.status_code == 200
# GET stage info 確認回到 task_execution
info = app_client.get(
f"/project/{seed_review_ap['project_uid']}/ap/{seed_review_ap['ap_uid']}/stage/info",
headers=headers,
).get_json()
assert info["data"]["current_stage"]["code"] == "task_execution"
def test_advance_non_review_stage_ignores_decision(app_client, headers, seed_planning_ap):
"""非 review stage 送 decision → ignore + log warning,不擋"""
resp = app_client.post(
f"/project/{seed_planning_ap['project_uid']}/ap/{seed_planning_ap['ap_uid']}/stage/advance",
json={"decision": "reject", "comment": "x"}, # planning 不該理 decision
headers=headers,
)
# Planning 推進條件 OK 就 200;非 review stage decision 被吞掉
# (此 test 主要驗 schema 不擋;log warning 用 caplog 驗)
assert resp.status_code in (200, 412) # 412 是因為 planning all_tasks_assigned precondition
def test_validate_endpoint_pass(app_client, headers):
xml = open("scripts/sql/seeds/bpmn/builtin-internal-check.bpmn").read()
resp = app_client.post(
"/flow-engine/flow-templates/validate",
json={"bpmn_xml": xml},
headers=headers,
)
assert resp.status_code == 200
assert resp.get_json()["data"]["valid"] is True
def test_validate_endpoint_fail(app_client, headers):
xml = open("tests/fixtures/bpmn/invalid_start_to_poam.bpmn").read() # 需建 fixture
resp = app_client.post(
"/flow-engine/flow-templates/validate",
json={"bpmn_xml": xml},
headers=headers,
)
assert resp.status_code == 200
body = resp.get_json()["data"]
assert body["valid"] is False
assert any(v["rule"] == "start_stage_not_allowed" for v in body["violations"])mkdir -p tests/fixtures/bpmn
# 把 M3 test 內 _xml_with_start_to_poam() 回傳的 XML 寫成檔tests/fixtures/bpmn/invalid_start_to_poam.bpmn:
<?xml version="1.0"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:camunda="http://camunda.org/schema/1.0/bpmn">
<bpmn:process id="P" isExecutable="true">
<bpmn:startEvent id="S"><bpmn:outgoing>F1</bpmn:outgoing></bpmn:startEvent>
<bpmn:userTask id="UT_p">
<bpmn:extensionElements><camunda:properties>
<camunda:property name="stage_object_code" value="poam"/>
</camunda:properties></bpmn:extensionElements>
<bpmn:incoming>F1</bpmn:incoming><bpmn:outgoing>F2</bpmn:outgoing>
</bpmn:userTask>
<bpmn:endEvent id="E"><bpmn:incoming>F2</bpmn:incoming></bpmn:endEvent>
<bpmn:sequenceFlow id="F1" sourceRef="S" targetRef="UT_p"/>
<bpmn:sequenceFlow id="F2" sourceRef="UT_p" targetRef="E"/>
</bpmn:process>
</bpmn:definitions>既有 tests/conftest.py 有 session-scoped app / app_client / token / headers fixture(line 99~228)— mirror 該 pattern,加 function-scoped fixture:
import pytest
from app.project.service.oscal_project_service import OscalProjectService
from di_containers.containers import Containers
@pytest.fixture
def seed_review_ap(app, headers):
"""建一個用「完整稽核流程(含審核)」範本的 AP,推進到 review stage。
依賴:dev DB 已 seed builtin-full-audit-with-review (M4)
測試帳號 blsit 是某 tenant 內 manager
"""
container = Containers.grc_container
oscal_project_service = container.oscal_project_service()
flow_template_domain_service = Containers.flow_engine_container.flow_template_domain_service()
with app.app_context():
master = flow_template_domain_service.get_builtin_by_name("完整稽核流程(含審核)")
assert master is not None, "M4 builtin seed missing"
project = oscal_project_service.start_oscal_project(
project_name=f"test-spec4-review-{pytest.helpers.uuid4_short()}",
...其他 required params 依既有 fixture pattern 補...,
flow_template_uid=master.uid,
curr_user="blsit",
)
# 此時 AP 在 planning stage;接著 advance planning → task_execution → review
ap_uid = project.assessment_plans[0].uid
# ... advance helper(mirror Spec 3 test fixture pattern)...
return {"project_uid": str(project.uid), "ap_uid": str(ap_uid)}
@pytest.fixture
def seed_planning_ap(app, headers):
"""同上但停在 planning stage(給 non-review-stage test 用)"""
...注意:fixture 寫法需 mirror 既有 spec 2/3 test pattern(如 tests/test_stage_advance_service_metadata.py 若存在);若 advance helper 不好寫,可改用 manual seed DB(INSERT row 直接到 PROCESSING 階段)— 但這較脆弱,不推薦。
如果 dev DB 上跑 integration test 較複雜,可改採 unit test with mocks(mock domain services + stage_registry),把這幾 case 改成 service-level test — 對齊 Spec 3 §16.1 條 3 的 deferral pattern。
pytest tests/test_stage_advance_v4.py -vExpected: all pass。fixture seeding 可能需要 conftest 補助 — 若 dev DB 無「review stage AP」現成資料,可改用 app_service.start_oscal_project(flow_template_uid=...) 在 fixture 內建。
git add tests/test_stage_advance_v4.py tests/fixtures/bpmn/
git commit -m "$(cat <<'EOF'
test(spec4-M9): StageAdvance decision/comment integration tests
對應 spec 4 design.md §12.1:
- approve no-comment / reject required-comment / reject with-comment
- 非 review stage 送 decision 不擋(ignore)
- validate endpoint pass / fail
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"此段在
compliance-manager-ferepo 進行。BE plan 已完成,可獨立交付。 跨 repo commit 不可 atomic:BE M1~M9 commit + push 完才切到 FE。 切到 FE repo:cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe git fetch git checkout feature/project-flow-engine-review 2>/dev/null || git checkout -b feature/project-flow-engine-review
Files:
src/locales/zh-TW.jsonsrc/locales/en.json{
"flowEngine": {
"validator": {
"successor_not_allowed": "「{source_stage}」階段後不可接「{target_stage}」階段",
"start_stage_not_allowed": "流程不可從「{stage}」階段開始",
"end_stage_not_allowed": "「{stage}」階段不可直接結案",
"unknown_stage_code": "未知的階段代碼:{code}",
"gateway_missing_condition": "決策節點「{gateway_id}」缺少分支條件",
"unreachable_node": "節點「{node_id}」無法從開始點抵達",
"no_end_path": "找不到從開始到結束的完整路徑"
},
"banner": {
"review": {
"submit": "送出審核",
"reject": "退回",
"rejectReasonRequired": "請說明退回原因(必填)",
"commentOptional": "可選填說明"
}
}
}
}FlowPhaseBanner.vue 雙按鈕 + ConfirmDialog commentFiles:
src/components/grc/FlowPhaseBanner.vue設計重點:同一個 Dialog instance 被所有 stage 共用,靠 rejectMode flag 切換 placeholder 與 confirm-button disable 條件。對應 design §10.3「全 stage advance 加可選 comment textarea (reuse 同元件)」要求 — 非 review stage 點主按鈕也走 openAdvanceDialog(rejectMode=false) 開同一個 Dialog;review reject 按次按鈕走 openRejectDialog(rejectMode=true)。
詳細 UI 跟 design §10.1 對應;ConfirmDialog confirmation 流程:
<template>
...既有 banner...
<Button v-if="currentStage?.code === 'review'"
severity="secondary"
@click="openRejectDialog">
{{ $t('flowEngine.banner.review.reject') }}
</Button>
<Button @click="openAdvanceDialog">
{{ buttonLabel }}
</Button>
<Dialog v-model:visible="advanceDialogVisible" :header="$t(...)" modal>
<Textarea v-model="comment" rows="4" autoResize
:placeholder="commentPlaceholder" />
<template #footer>
<Button :label="$t('common.cancel')" text @click="advanceDialogVisible=false"/>
<Button :label="$t('common.confirm')"
:disabled="rejectMode && !comment.trim()"
@click="submitAdvance" />
</template>
</Dialog>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const advanceDialogVisible = ref(false)
const rejectMode = ref(false)
const comment = ref('')
const commentPlaceholder = computed(() =>
rejectMode.value
? t('flowEngine.banner.review.rejectReasonRequired')
: t('flowEngine.banner.review.commentOptional')
)
function openAdvanceDialog() {
rejectMode.value = false
comment.value = ''
advanceDialogVisible.value = true
}
function openRejectDialog() {
rejectMode.value = true
comment.value = ''
advanceDialogVisible.value = true
}
async function submitAdvance() {
await advanceStage({
decision: rejectMode.value ? 'reject' : 'approve',
comment: comment.value.trim() || undefined,
})
advanceDialogVisible.value = false
emit('advanced')
}
</script>
Files:
src/service/FlowPhaseService.js(或對應 stage advance service)export async function advanceStage(projectUid, apUid, payload) {
// payload: { force?, decision?, comment? }
const { data } = await api.post(
`/project/${projectUid}/ap/${apUid}/stage/advance`,
payload,
)
return data
}Files:
src/views/flow-template/FlowTemplateEditView.vueimport { debounce } from 'lodash'
const violations = ref([])
const validateBpmn = debounce(async (xml) => {
try {
const { data } = await flowTemplateService.validate({ bpmn_xml: xml })
violations.value = data.violations || []
highlightViolations(violations.value)
} catch (err) {
// 400 from BE — extract violations from envelope.data
violations.value = err?.response?.data?.data?.violations || []
highlightViolations(violations.value)
}
}, 500)
// 監聽 bpmn-js modeler 的 commandStack changed 事件
modeler.on('commandStack.changed', () => {
modeler.saveXML({ format: true }).then(({ xml }) => validateBpmn(xml))
})<aside v-if="violations.length" class="validator-panel">
<h4>流程結構警告</h4>
<ul>
<li v-for="(v, i) in violations" :key="i">
{{ $t(v.reason_i18n_key, v.context) }}
</li>
</ul>
</aside>
function highlightViolations(viols) {
const canvas = modeler.get('canvas')
// clear previous markers
modeler.get('elementRegistry').getAll().forEach(el =>
canvas.removeMarker(el.id, 'violation-marker')
)
viols.forEach(v => {
const flowId = v.context?.sequence_flow_id
if (flowId) canvas.addMarker(flowId, 'violation-marker')
})
}CSS:
.djs-element.violation-marker .djs-visual > * {
stroke: var(--danger-color, #dc3545) !important;
stroke-width: 2px !important;
}Files:
src/views/flow-template/components/BpmnPaletteProvider.js(或 palette config)依既有 4 個 stage palette 模式加:
'create.review-task': createTask({
type: 'bpmn:UserTask',
name: '審核',
properties: {
'stage_object_code': 'review',
'main_role': 'reviewer',
},
}),cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe && npm run dev依 design §12.2 必過項操作:
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
git add src/locales/ src/components/grc/FlowPhaseBanner.vue \
src/views/flow-template/ src/service/FlowPhaseService.js
git commit -m "$(cat <<'EOF'
feat(spec4-fe): Banner approve/reject + BPMN editor inline warning
對應 BE spec 4 design.md §10:
- FlowPhaseBanner review 階段加退回按鈕 + ConfirmDialog comment textarea
- 全 stage advance ConfirmDialog 加可選 comment 欄位
- BPMN editor 拖 sequenceFlow 時 debounce 呼叫 /flow-templates/validate
- 違規 sequenceFlow 紅色 outline + 側邊欄 warning list
- Palette 加 review UserTask 預設 stencil
- i18n zh-TW / en 對應 keys
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"依 design §12.2 完整跑一輪:
cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
git checkout feature/project-flow-engine-reviewfeatures/flow-engine-review/review-stage.feature:
Feature: Review stage approve/reject flow
Scenario: Reviewer rejects with reason, PM re-submits, reviewer approves
Given a project with "完整稽核流程(含審核)" template, PM "blsadmin" and reviewer "blsreviewer"
And the AP is in "review" stage
When reviewer logs in and rejects with reason "缺證據"
Then the AP returns to "task_execution" stage
And the job comment "缺證據" is visible in the audit trail
When PM logs in and advances "task_execution" → "review"
And reviewer approves with no comment
Then the AP moves to "audit" stage
對應 step definition + page object(mirror 既有 banner BDD pattern)。
npm run test:bdd -- --tags @flow-engine-reviewFiles:
docs/changelog/2026-05-14-feat-review-stage-flow-validator.mddocs/changelog/2026-05-14-feat-review-stage-flow-validator.md---
type: feat
breaking: false
modules: [flow-engine, grc]
issue: ~
commit: <bumped after commit>
---
# Review stage 階段 + 流程方向性驗證(Spec 4)
## 需求說明
在 BPMN 流程中加入 reviewer 過審環節(approve/reject),不通過時由範本指定退回路徑;
範本 create/update 時加方向性驗證避免不合理流程(如 start → poam)。
## 變更範圍
### Schema
- `compliance.stage_objects` 加 4 欄:can_start / can_end / allowed_predecessors / allowed_successors
- seed 第 5 個 stage_object: `review`
### API
- `POST /flow-engine/flow-templates/validate` — 純驗證 endpoint(不存檔)
- `POST /flow-engine/flow-templates`(create / update)— 加 topology 驗證
- `POST /project/<uid>/ap/<ap>/stage/advance` — 加 decision/comment optional 欄位
- decision=reject 必填 comment(BE 強制,GRC_400061)
- 非 review stage 送 decision 忽略 + log warning
### Service
- `FlowTemplateAppService._validate_bpmn_topology` 整合 7 條規則驗證
- `StageAdvanceService.advance_stage` 接 decision/comment + reuse JobCommentService
- 新 `ReviewDecisionHandler` 註冊到 stage_registry
### Builtin
- 新範本 `builtin-full-audit-with-review`(並列既有 builtin-full-audit)
- 既有 builtin-full-audit + 自訂 duplicate 範本 backfill:Flow_poam_audit 加 reverse extension property
### Error code(3 個新)
- `GRC_400060` 範本流程結構不合法
- `GRC_400061` 退回審核必須附留言
- `GRC_400062` decision 值不合法
## 測試結果
- pytest `tests/test_bpmn_topology_validator.py` — 14 條 pass(含 9 條 regex 邊界 case)
- pytest `tests/test_stage_advance_v4.py` — 6 條 integration test pass
- Manual smoke 6 步完整跑通
## 參考資訊
- spec: `docs/features/FR-026-2605-project-flow-engine-integrate/04-review-stage-and-flow-validation/design.md` v1.1
- plan: `docs/features/FR-026-2605-project-flow-engine-integrate/04-review-stage-and-flow-validation/implementation-plan.md`不在本 plan 範圍(後續 spec):
| 版本 | 日期 | 變更 |
|---|---|---|
| v1 | 2026-05-14 | 初版,11 milestones / 50+ tasks / TDD style |
| v1.1 | 2026-05-14 | plan reviewer 反饋整合:(1) M5.3 / 全文 di_containers 檔名修正 flow_engine_containers.py → flow_template_containers.py;(2) M10.2 FE 路徑修正 src/components/grc/banner/ → src/components/grc/;(3) M8.4 route nickname pattern 對齊既有 user_ctx.nickname,不破壞 wiring;(4) M9 補 seed_review_ap / seed_planning_ap fixture 骨架 + 退路(unit test mock);(5) M4.3 Step 2 python script 改 str.replace 避免 regex metachar;(6) M10 跨 repo workflow 補 git checkout 指令;(7) M10.2 加 Dialog reuse pattern 說明;(8) M11.3 日期 placeholder 改 literal 2026-05-14 |