# FR-051 — 規劃階段推進前置檢核（Planning Advance Pre-checks）

> **狀態**：設計定稿待 user review（Phase 2 產出）
> **提出**：2026-07-21
> **來源 Notion case**：
> - 母題 `3a4346da-4cd0-80ed`「專案規劃階段, 再點擊要推進下一階段時, 需做一些基本檢核」（功能=專案規劃）
> - 子項 `3a3346da-4cd0-8020`「從 規劃 → 下一步, 如果有任務沒執行, 要跳出提醒, 給 User Confirm, 確認要往下才進行」（功能=專案總覽，母題內文 `@mention`）
> **arc 關聯**：relates to FR-038（round 狀態機 / stage_advance）、FR-050（stage 歷程 / Banner）

---

## 1. 需求白話（Phase 0 已與 user 對齊）

專案在**規劃階段（planning）**按 FlowPhaseBanner 的「推進下一階段」按鈕（實際觸發 `launch_audit`）時，推進**之前**做兩項基本檢核，各自若不滿足就跳提醒、讓 user confirm 放行才往下（**都不是硬擋**）：

- **檢核 A（子項 8020）**：本輪**規劃階段的證據蒐集 job**（per-AO prep-job）若還有沒執行完的（`not_started` / `in_progress` > 0），跳提醒帶「還有 N 個任務未完成」→ confirm 放行。
- **檢核 B（母題 80ed 第二點）**：專案綁定的**流程模板（BPMN）**裡所有 userTask 用到的**主要角色（main_role）**，逐一比對**專案參與人員**已指派的 role 集合；有缺（某 role 沒有任何參與人員）→ 跳提醒帶「缺少 X / Y 角色，流程可能無法進行」→ confirm 放行。

兩項檢核在「按推進」同一瞬間觸發，**聚合列在同一個 confirm 彌窗**（user 一次看完一次決定）。

### 1.1 五項設計決策（user 拍板）

| # | 決策 | 選定 |
|---|------|------|
| 1 | 檢核 A「任務」範圍 | **規劃階段的證據蒐集 job**（per-AO prep-job，`job_executions.status`）|
| 2 | 檢核 B 缺角色 → 硬擋 or 軟提醒 | **軟提醒 + confirm 放行**（對齊 case「提醒」語氣、與 A 同機制）|
| 3 | A / B 彌窗 | **共用單一彌窗、聚合列出所有 reasons** |
| 4 | FR 編號 | 拆兩個 → 本案 **FR-051**（檢核）；tab gate 另立 FR-052 |
| 5 | confirm 放行需要誰 | 沿用既有 `force` 語意：**manager**（見 §5.3）|

---

## 2. 現況盤點（實地 grep，非臆測）

### 2.1 推進機制既有兩條 confirm 路徑

`app/flow_engine/service/stage_advance_service.py::advance_stage`：

| 路徑 | 觸發 | UI 呈現 | 可否放行 |
|------|------|---------|----------|
| **precondition fail** | `_run_precondition` 回 `passed=False` | 按鈕 **disabled** + tooltip（`FlowPhaseBanner.vue:51`）| ❌ UI 不給 force（硬擋）|
| **handler warning** | handler `execute()` 回 `{"warning": ...}`（line 366-378）| ConfirmDialog → 按確認帶 `force:true` 重打（`FlowPhaseBanner.vue:442`）| ✅ manager confirm 放行 |

> **關鍵**：本案兩項檢核都要「軟提醒 + confirm 放行」→ **完全對應 handler-warning 路徑**，不是 precondition。故 planning 階段的 precondition `RoundPrepTasksDoneCheck`（目前 lenient）**維持不動**，檢查全放進 planning 的 completion handler。

### 2.2 檢核 A 的完成度信號（canonical，可直接 reuse）

`app/grc/service/project_service.py:560 get_ap_dashboard` → domain → `infra/grc/repository/grc_project_repo_impl.py`（約 528-577）：以 `project_uid` 統計 per-AO prep-job：

```
total_tasks, completed(je.status=COMPLETED), in_progress(PROCESSING), not_started(TODO)
```

→ 檢核 A 判定 = `not_started + in_progress > 0`。**不新造 SQL** —— 抽一個 domain 層 `count_incomplete_prep_jobs(project_uid)` 共用同一條 JOIN（見 §4.2），避免第二套 job 完成度真相（CLAUDE.md 禁重複造輪子）。

> prep-job 由 `app/project/service/project_start_app_service.py::_generate_prep_jobs` 在「專案成立」建（per-AO，`workflow_execution` + `job_execution`，`status=TODO`），指派由 PM 之後手動。

### 2.3 檢核 B 的角色來源（實地確認）

- **模板 userTask main_role**：BPMN userTask extension property `main_role`（`StageAdvanceService._resolve_main_roles` 已在讀，可 comma 分隔多值）。列舉整份模板 userTask → `jedi_flow_engine.common.utils.bpmn_uilts.BpmnUtils`（`.jobs` 逐一 `.properties.get("main_role")`）。`app/flow_engine/util/bpmn_topology_validator.py` 已有 `_collect_user_tasks` + extension 解析可參考。
- **模板 XML 來源**：規劃階段本輪已有 `workflow_execution`（`_resolve_workflow_context(round_uid)` 回 `workflow_template_xml`）。planning 是主流程第一個 UserTask，此時整份主流程 XML 已可讀。
- **參與人員 role**：`ParticipantRole` StrEnum ∈ {manager, reviewer, auditor, viewer}（`common/enum/participant_enum.py`）；查 `ProjectParticipantDomainService` 拿專案所有 participant 的 `role` 集合。
- **既有 role-coverage 檢查**：grep 全專案 **無**（全新邏輯）。

> ⚠️ **main_role 值域對齊**：BPMN `main_role` 存的是否等於 `ParticipantRole` 值（manager/auditor/…）需在 plan Step 0 抽樣真實模板 XML 驗證。若模板存的是 stage_object 角色碼（另一套詞），比對前要 normalize；此為 plan 的前置查證第一項（見 implementation-plan §Step 0）。

---

## 3. User Mental Walkthrough（SOP 規則 2 強制）

```
1. Manager 進「專案規劃頁 / 專案總覽頁」→ 看到 FlowPhaseBanner，當前階段=規劃，
   主按鈕「啟動稽核」（label 由 BE 動態給）。
2. Manager 點「啟動稽核」→ 既有既有二次確認 ConfirmDialog（"確定啟動稽核?"）→ 按確定
   → 前端 advance(force=false)。
3. BE planning handler 先跑 PlanningReadinessChecker：
   3a. 檢核 A：本輪還有 3 個證據蒐集 job 未完成 → warning A。
   3b. 檢核 B：模板 userTask 需要 auditor / reviewer，但參與人員只有 manager
       → warning B（缺 auditor, reviewer）。
   → handler 回 {"warning": {...}, "reasons": [A, B]}，advance_stage halt、不推進 BPMN。
4. 前端收到 needsConfirmation → 開「聚合 ConfirmDialog」：
      標題：仍要繼續推進?
      內容（條列）：
        • 還有 3 個規劃階段任務尚未完成
        • 流程需要「稽核員 / 審核者」角色，但專案參與人員尚未指派，流程可能無法進行
      [取消] [仍要推進]
5a. Manager 按「取消」→ 停在規劃階段，什麼都沒變（可回頭補任務 / 補人員角色）。
5b. Manager 按「仍要推進」→ 前端 advance(force=true) →
    BE 跳過檢查 → launch_audit 正常執行 → 階段推進、toast「已成功推進階段」。
6. 若兩項檢核都通過（無 warning）→ 步驟 3 之後直接推進，不跳聚合彌窗
   （步驟 2 的既有二次確認仍在，維持不變）。
```

**邊界 / 錯誤路徑**：
- 非 manager 角色：規劃階段 main_role 通常僅 manager，非 manager `user_can_advance=false` → 根本看不到推進按鈕（既有行為，不變）。
- `force=true` 但非 manager：既有 `advance_stage` line 303 擋 `GRC_NOT_MANAGER`（不變）。
- 模板無 main_role 宣告 / 拿不到 XML：檢核 B skip（fail-open，只 log warning，不阻推進）—— 檢核是「提醒」不是「守門」，拿不到資料時不該擋 manager。
- prep-job dashboard 查詢失敗：檢核 A skip（同上，fail-open + log）。

---

## 4. DDD 設計

### 4.1 層級落點

| 層 | 檔案 | 動作 |
|----|------|------|
| Handler | `app/grc/service/oscal_stage_handlers.py::LaunchAuditOnCompleteHandler` | `execute()` 內、呼叫 `launch_audit` 前先跑 checker；`force` 從 `ctx` 讀 |
| **新** App helper | `app/grc/service/planning_readiness_checker.py`（新檔）| `PlanningReadinessChecker.check(round_uid, project_uid) -> list[Warning]` |
| Domain（檢核A）| `domain/grc/service/grc_project_domain_service.py` + repo | 加 `count_incomplete_prep_jobs(project_uid) -> int`（reuse get_ap_dashboard 同 JOIN）|
| Domain（檢核B）| 既有 `ProjectParticipantDomainService` | 讀專案 participant role 集合（已有 list 方法）|
| BPMN 解析 | `jedi_flow_engine...BpmnUtils` | 列 userTask main_role（唯讀，無套件改動）|

> `PlanningReadinessChecker` 不加 `@transaction`（caller `LaunchAuditOnCompleteHandler.execute` 已在 `advance_stage` 的 `@transaction` scope 內）；docstring 標「caller 必須在 @transaction scope 內」。

### 4.2 檢核 A — domain count 方法（reuse SQL）

`grc_project_repo_impl.py` 抽出 per-AO prep-job 的 status 統計為可共用查詢，新增：

```python
def count_incomplete_prep_jobs(self, project_uid: str) -> int:
    """本輪規劃階段 per-AO 收證據 job 中 status != COMPLETED 的數量。
    reuse get_ap_dashboard 同一 JOIN（je.status FILTER），只 COUNT(*) FILTER (WHERE je.status != COMPLETED)。"""
```

Checker：`n = count_incomplete_prep_jobs(project_uid); if n > 0: warnings.append(Warning("planning_prep_jobs_incomplete", {"count": n}))`

### 4.3 檢核 B — role-coverage 演算法

```python
# 1. 拿本輪主流程 XML（_resolve_workflow_context 已有；或 checker 自行查 audit_round → workflow_execution）
# 2. BpmnUtils(xml).jobs → 收集所有 userTask 的 main_role（split ","），去重成 required_roles
#    - 過濾掉非參與角色語意的值（若有；plan Step 0 驗）
# 3. participant_roles = {p.role for p in participants}（該專案全部參與人員）
# 4. missing = required_roles - participant_roles
# 5. if missing: warnings.append(Warning("planning_roles_unassigned", {"roles": sorted(missing)}))
```

### 4.4 Warning 聚合形狀（BE → FE 契約）

`LaunchAuditOnCompleteHandler.execute` 在有 warning 且 `not force` 時回：

```jsonc
{
  "warning": "planning_readiness",          // 既有 advance_stage 只看 truthy 就 halt
  "reasons": [                              // 新增聚合欄位
    { "code": "planning_prep_jobs_incomplete", "context": { "count": 3 } },
    { "code": "planning_roles_unassigned",    "context": { "roles": ["auditor", "reviewer"] } }
  ]
}
```

- `advance_stage` line 366 既有判斷 `handler_result.get("warning")` 為真即 halt、回 `{"advanced": false, "handler_result": ...}`，**不需改 advance_stage 核心**（只是 handler_result 多帶 reasons）。
- `force=true`：checker 不跑（或跑但忽略），直接 `launch_audit`。

---

## 5. 權限 / 前置條件 / Error handling

### 5.1 權限
- 推進按鈕本身：既有 `main_roles` + manager override（`advance_stage` line 292）——不變。
- `force=true` 放行：既有 line 303 限 manager（`GRC_NOT_MANAGER`）——不變。**本案不新增權限碼**（檢核走 warning 非 error）。

### 5.2 前置條件
- planning 階段既有轉換前置（`launch_audit` line 371：status=planning、living_ssp 存在）——不變，檢核 A/B 疊加在其前。

### 5.3 Error / i18n
- 檢核走 **warning（非 error code）**，沿用 `flow_engine.precondition.*` 同族的 i18n warning key（`FlowPhaseBanner` 既有 `getPreconditionMessage` / `force_confirm` 機制）。
- 新增 FE i18n key（`{en, tw, cn}` 三語，per CLAUDE.md BE→FE i18n 同步）：
  - `lang.flow_engine.readiness.prep_jobs_incomplete`（含 `{count}` 插值）
  - `lang.flow_engine.readiness.roles_unassigned`（含 `{roles}` 插值）
  - `lang.flow_engine.readiness.confirm_header` / `confirm_accept` / `confirm_reject`
- 角色顯示名：`roles` 陣列（auditor/reviewer…）FE 轉 `lang.grc_participants.role_*` 既有翻譯。

---

## 6. FE 設計（frontend-spec 摘要，細節見 FE 實作）

`FlowPhaseBanner.vue`：
- `doAdvance` 的 `needsConfirmation` 分支（line 442）已存在 → 只需把 `warningMessage` 從單句擴成「讀 `handler_result.reasons` 陣列 → 逐條 t() → 換行聚合」。
- `useStageInfo.js::advance` 需把 BE 回的 `handler_result.reasons` 透出到 `result.reasons`（目前只透 `needsConfirmation` / `warningMessage`）。
- 兩頁（`ProjectPlanningView` / `ProjectAuditorOverview`）都嵌 Banner → **零額外改動即雙頁涵蓋**。
- ConfirmDialog 用既有 PrimeVue `useConfirm`（Banner 已 import），內容改多行 HTML/列表。

---

## 7. 不在範圍（YAGNI）
- 不動 precondition `RoundPrepTasksDoneCheck`（維持 lenient；檢查全在 handler warning 層）。
- 不改 `advance_stage` 核心流程（只擴 handler_result 欄位）。
- 不做其他階段（ap_authoring / audit / poam）的推進檢核 —— case 只講規劃階段。
- 不做「自動指派缺角色」/「自動完成任務」—— 只提醒。
- 不擋非 manager（既有 user_can_advance 已處理）。

---

## 8. 風險 / 待查證（plan Step 0 前置）
1. **BPMN main_role 值域** 是否等於 `ParticipantRole` 值 —— 抽真實模板 XML 驗（§2.3 ⚠️）。
2. `count_incomplete_prep_jobs` 的「本輪」scope：prep-job 是專案層級（非 round 綁定，見 `RoundPrepTasksDoneCheck` 註解）——多輪專案時是否要只算當前輪？規劃階段通常是首輪，先按「專案全部 in-scope prep-job」，多輪情境列 follow-up。
3. `handler_result.reasons` 透出鏈：確認 `advance_stage` line 373-378 halt 分支把整個 `handler_result` 原樣回 FE（含新增的 reasons）——讀碼確認無 schema 白名單濾掉。
