---
title: "專案規劃頁新增任務與平行流程 — 需求討論稿 (FR-112)"
brand: "Guidant AI · **FR-112** 規劃頁新增任務與平行流程"
eyebrow: "FR-112 · 任務真相收回規劃頁、流程圖降為走向設計 · 需求討論稿 · 2026-09-19（v1 · 待審）"
h1: "任務在規劃頁上開，流程圖只管先後順序"
lede: "目前要替一個查核項目多開一個任務，唯一的辦法是打開流程圖編輯器、從工具列拖一個方塊出來——PM 不會用、也不該被要求會用。本案把「新增任務」放回專案規劃頁，並把新開的任務預設接成**平行**（誰先做都行、全部做完這個查核項目才算過）；流程圖編輯器降級為純粹的「走向設計工具」，只管先後與分支，不再是填任務資料的地方。"
chips: [
  {text: "方向已拍板 4 項（決策者 2026-09-19）", kind: ok},
  {text: "待決策 D1–D6", kind: warn},
  {text: "跨 3 repo：jedi-flow-engine／BE／FE", kind: accent},
  {text: "前身：2026-06-17 雙向同步診斷（未拍板）", kind: accent},
  {text: "🔴 實查發現引擎缺口：合流閘道不等兄弟分支", kind: crit}
]
footer: "FR-112 · 專案規劃頁新增任務與平行流程 — 需求討論稿 · 2026-09-19 v1 · 現況依據：`api/flow_control/routes/job_route.py`、`infra/flow_control/repository/flow_control_job_repo_impl.py`、`app/flow_engine/service/workflow_execution_service.py`、`jedi-flow-engine/.../bpmn_uilts.py`、FE `ProjectPlanningView.vue`／`WorkflowSetupEditor.vue` 實檔查證 · 沿革見 LOG 與 git log"
---

## 分工概述（30 秒版） {#overview nav="概述"}

整案一句話：**任務的所有資料以專案規劃頁為準存進資料庫，流程圖只留「先後與分支」這一件事；規劃頁補上「新增任務」按鈕，新任務預設掛成平行分支，該查核項目要全部任務做完才算通過。**

給非工程讀者的三個名詞：

- **查核項目（AO）**：一條法規控制項底下再細分的檢查點，規劃頁左邊樹狀清單點到的最小單位。每個查核項目底下掛一串任務。
- **流程圖（BPMN）**：用方塊與箭頭畫出來的工作流程。方塊叫 `userTask`（一個方塊＝一個任務），箭頭表示先後順序，菱形的叫「閘道」——**平行閘道**表示「這幾條同時開跑」、**互斥閘道**表示「二選一」。
- **任務資料表（`job_executions`）**：資料庫裡真正存任務的地方——名稱、描述、負責人、狀態、證據、問卷、檢測工具設定都在這裡。

| 階段 | 做什麼 | 產出 | 完成怎麼判定（決策者親手檢查法） |
|---|---|---|---|
| **① 流程圖改圖工具** | 在共用套件裡補三支能力：把流程重排成標準平行結構、在合流閘道前掛一條新分支、合流閘道要等所有兄弟分支都完成才放行 | jedi-flow-engine 新增／修正的函式 ＋ 單元測試 | 跑套件測試：給一張「兩條平行分支、合流後還有一個任務」的流程圖，完成第一條分支後，合流後的任務**不可以**變成進行中；兩條都完成才可以 |
| **② 後端新增／刪除任務** | 新增任務端點不再要求前端先給流程圖節點編號——後端自己在流程圖上補節點、接線、回寫任務編號；刪除任務時把節點從流程圖上拿掉並把線接回去 | BE app service ＋ repo 改動 ＋ 存檔驗證 | 用 Swagger 直接打新增任務端點（**不帶** `job_element_uid`）→ 回 200；去資料庫看該查核項目的流程圖 XML，多了一個方塊、且方塊上帶著新任務的編號 |
| **③ 規劃頁按鈕** | 任務區塊補「新增任務」與「刪除任務」；新任務展開就是既有的任務表單 | FE `ProjectPlanningView.vue` | 規劃頁點任一查核項目 → 按「新增任務」→ 填名稱存檔 → 重新整理仍在 → 打開流程圖編輯器，看得到這個新方塊，而且是與原任務**並排**（平行）不是串在後面 |
| **④ 流程圖編輯器瘦身** | 屬性面板只留「名稱」；存檔不再由前端逐一打新增／更新任務端點（改由後端存檔時處理） | FE `WorkflowSetupEditor.vue` | 打開編輯器點一個方塊 → 右邊只剩名稱欄，沒有任務類型／問卷／設備／部門；拖一個新方塊出來存檔 → 回規劃頁看得到一筆新的空任務（名稱是你在圖上打的那個） |
| **⑤ 驗收與回歸** | 專案啟動、輪次推進、批次完成、任務佇列四條既有動線在平行結構下重跑 | e2e 回歸（測試 repo） | 起一個新專案 → 規劃頁加兩個任務 → 兩個都完成 → 該查核項目變「已完成」；只完成一個時**不可以**變完成 |

依賴：①是②的地基（②要呼叫①補的改圖函式）；③④在②完成後可平行；⑤等③④都驗收過才做。

---

## 為什麼要做這件事 {#why nav="背景"}

### 現在的樣子

專案規劃頁（`ProjectPlanningView.vue`）的任務區塊可以改任務的每一個欄位——名稱、描述、指南、類型、負責人、部門、設備、問卷、檢測工具參數，存檔走 `saveTask()`（FE `ProjectPlanningView.vue:1772`）。**唯獨不能新增，也不能刪除**——實查該檔案，找不到任何 `addTask` / `deleteTask` 之類的函式。

要多開一個任務，PM 得：打開流程圖編輯器（`openBpmnEditor()`，同檔 `:1923`，開新分頁到 `/workflow/workflow-setup`）→ 從左邊工具列拖一個方塊出來 → 手動接線 → 在右邊屬性面板填一堆欄位 → 存檔。

::: {.callout .crit}
**🔴 這條動線對 PM 是不合理的要求**

流程圖編輯器是工程師與流程設計者的工具。要 PM 為了「多派一件事給同仁」而去學拖方塊、接箭頭、分辨平行閘道與互斥閘道，等於把工具的複雜度轉嫁給使用者。
:::

### 過去為什麼會走成這樣：兩份真相在互相覆蓋

任務的同一份資料現在存在**兩個地方**：

| 存在哪 | 存了什麼 | 誰在寫 |
|---|---|---|
| 資料庫 `job_executions`（＋ `task_assignees` 等關聯表） | 名稱、描述、指南、類型、狀態、時間、負責人、部門、設備、問卷、檢測工具 | 規劃頁存檔、後端流程引擎 |
| 流程圖 XML 的 camunda 屬性 | `name`、`workDescription`、`guide`、`actionType`、`actionInfo`、`devices`、`departments`、`toolParams`、`completionMode`、`job_execution_uid` | 流程圖編輯器存檔、後端 `_sync_task_to_template_xml` 回寫 |

對照表在 FE `docs/guides/grc-page-data-models.md:40-47`。

兩邊互相覆蓋的具體路徑（實查程式碼）：

1. **規劃頁 → 流程圖**：任務存檔後，後端 `_sync_task_to_template_xml()`（`infra/flow_control/repository/flow_control_job_repo_impl.py:401`）把名稱／描述／指南／類型／問卷／工具參數寫回流程圖 XML 的 camunda 屬性。這是**單向**的，方向正確。
2. **流程圖 → 規劃頁**：編輯器存檔時 `onSaveClick()`（FE `WorkflowSetupEditor.vue:402`）先存 XML，再呼叫 `syncJobExecutions()`（同檔 `:489`）——**逐一走訪圖上每個方塊**，有 `job_execution_uid` 就打 PUT 更新既有任務、沒有就打 POST 新建，然後 `patchJobExecutionUids()`（同檔 `:443`）把新任務編號寫回 XML、**再存一次 XML**、再重新載入圖。

第 2 條是問題所在：編輯器只有五個欄位（`name` / `workDescription` / `guide` / `actionType` / `actionInfo`），但它送出的 PUT 會覆蓋規劃頁在同一筆任務上設好的東西。程式碼裡留著防禦註解可以佐證這件事真的發生過——`syncJobExecutions()` 裡寫著「只在 `actionInfo` 非空時帶 surveys⋯避免把規劃頁設的問卷誤刪」「不帶 `approval_required`⋯不誤蓋規劃頁審查旗標」（FE `WorkflowSetupEditor.vue:525-531`）。

::: {.callout .warn}
**這些防禦註解就是「兩份真相」的病徵**

每加一個規劃頁專屬的欄位，就得在編輯器同步邏輯裡補一條「這欄不要蓋」的例外。例外會愈來愈多，而漏掉一條的症狀是**資料靜靜消失、沒有錯誤訊息**。
:::

歷史文件 `docs/analysis/2026-06-17-ao-statement-key-mismatch-and-task-bpmn-sync.md` 第二節當時就爬出這個落差，結論停在「需 user 確認任務粒度後再實作雙向同步」，一直沒拍板。本案就是那次待拍板事項的落地——**而且方向與當時討論的「雙向同步」相反：不做雙向，改成單一真相。**

### 決策者這次裁定的方向

::: {.callout .decided}
**四條已拍板（2026-09-19）**

1. **規劃頁為主、流程圖為輔**。任務的業務資料真相在 `job_executions`；流程圖 XML 只負責兩件事——拓樸（誰先誰後、誰跟誰平行），以及用 `job_execution_uid` 屬性指回是哪一筆任務。名稱寫進圖裡只是為了讓圖看得懂，是**顯示副本**，由後端單向寫入。
2. **規劃頁加「新增任務」**，新增後流程預設改成平行式：開始 → 分叉 → 全部任務並排 → 合流 → 結束。該查核項目要所有任務都完成才算通過。
3. **流程圖編輯器降為純流程走向設計**。可以改線（拉成一條龍、加二選一分支）；**也保留在圖上拖新方塊**——存檔時後端發現有方塊沒對應任務，就建一筆空任務並回寫編號。屬性面板只留名稱，細節回規劃頁填。前端不再自己跑兩次存檔。
4. **刪除任務由規劃頁做**，後端負責從圖上移掉節點並把線接回去。
:::

---

## 業界怎麼做 {#industry nav="業界對照"}

這個「流程圖只描述走向、業務資料另存」的分法不是本案發明的，是流程引擎領域的通則。

::: grid2
::: {.card .ok}
#### Camunda / Flowable（流程引擎）

流程定義（BPMN 檔）是**不可變的部署物**——部署後有版號，改了就是新版本，已在跑的案子繼續用舊版。真正的業務資料放在「流程變數」與外部業務表，不寫進 BPMN 檔本身。

BPMN 上的 `userTask` 屬性主要是**路由用**（誰該收到這件事、什麼條件往哪走），不是拿來存「這個任務的負責人叫什麼、他交了幾份證據」。

對本案的啟示：**把任務欄位塞進 camunda 屬性，一開始就偏離了這個工具的設計意圖**。
:::

::: {.card .ok}
#### Asana / Jira 這類任務工具

子任務**預設彼此不相依**——建了就都可以開始做，沒有「要等前一個」這回事。要有先後關係，得另外手動設定「阻擋／被阻擋」。

母任務的完成判定就是「子任務全部完成」。

對本案的啟示：決策者裁的「新增後預設平行、全部完成才算過」與這個直覺一致。**使用者建兩個任務時，心裡想的就是「這兩件事都要做」，不是「先做A再做B」。**
:::
:::

兩邊合起來就是本案的形狀：**任務像 Asana（預設互不相依、全做完才算過），流程圖像 Camunda（只管走向、不存業務資料）**。

---

## 現況盤點 {#inventory nav="現況盤點"}

::: statgrid
::: stat
[3]{.v}[涉及 repo]{.k}
:::
::: {.stat .warn}
[10]{.v}[寫進流程圖的 camunda 屬性]{.k}
:::
::: {.stat .crit}
[2]{.v}[引擎缺口落點（同一個 bug 兩處）]{.k}
:::
:::

### 後端（compliance-manager-be）

| 元件 | 現況（實查） | 本案動作 |
|---|---|---|
| `api/flow_control/routes/job_route.py:170` `ProjectJobCreateResource` | 新增任務端點**還在**（`POST /grc/project/<uid>/assessment-object/<ao_uid>/jobs`），註冊在 `api/flow_control/__init__.py:62,117`；現在只有流程圖編輯器在呼叫 | 端點保留，讓它接受**不帶節點編號**的呼叫 |
| `api/flow_control/serializers/job.py:322` `job_element_uid` 欄位 | `allow_none=True, load_default=None`——**schema 本來就允許不帶** | 不改；改的是下游的 repo |
| `infra/.../flow_control_job_repo_impl.py:673` `create_job()` | 開頭 `if not job_element_uid: return None`（`:698`）——**沒帶節點編號就默默回 None**，路由層轉成 `return_response(False, None)`，前端看不出發生什麼事 | 🔴 **核心改動**：沒帶編號時改成「後端自己在圖上插節點」，插完再往下走原邏輯 |
| 同檔 `create_job()` 的冪等防護（`:729`） | 同一流程下同一 `template_job_id` 已有任務 → 更新後回傳、不重建。防編輯器重複 POST | 保留。後端自己插節點時編號必定是新的，不衝突 |
| 同檔 `create_job()` 的初始狀態判定（`:750`） | 該流程下沒有未完成的任務 → 新任務開 `PROCESSING`；否則 `TODO` | ⚠️ 平行結構下這個判定要重看，見下方風險 R3 |
| 同檔 `_sync_task_to_template_xml()`（`:401`） | 規劃頁存檔後單向回寫圖：定位方式「先找 `job_execution_uid` 屬性、退而求其次比對節點 id」；屬性不存在會建立；**同時寫主表與翻譯表**（檔內註解記著只寫主表會讓編輯器永遠讀到舊值） | 保留且成為**唯一**的圖寫入路徑；寫入欄位範圍可收斂（見 D4） |
| 同檔 `delete_job()`（`:779`） | 刪任務時清五張關聯表，再呼叫 `BpmnUtils.remove_user_task_from_xml()` 從圖上移節點（註解寫「前端存檔通常已移除，這裡兜底」） | 從「兜底」升格為**主要路徑**——規劃頁刪除就是走這裡 |
| 同檔 `_compute_can_revert_job()`（`:350-396`） | 判「這個任務可不可以退回」：`len(all_jobs)==1` 直接可以；否則取 `get_next_jobs()` 的**第 0 筆**看是不是進行中 | ⚠️ 平行結構下「下一個任務」有多筆，只看第 0 筆會誤判，見風險 R4 |
| `app/flow_control/service/job_service.py:117` `create_job()` | `@transaction`，第一件事 `_require_manager(project_uid)`（`:94`，走 `assert_project_manager`）；接著處理問卷 snapshot、`task_survey` 笛卡爾積、檢測工具綁定與參數加密 | 守門與後續處理都沿用，不動 |
| `app/flow_engine/service/workflow_execution_service.py:602` `complete_job()` | 完成任務後用 `get_next_jobs()` 找下一批。**沒有下一批時**（`:677`）檢查整條流程還有沒有非 `COMPLETED`／`CANCEL` 的任務，全完成才把流程標 `COMPLETED`（`:687`）——**已經是「全部完成才通過」的語意** | 這段正確，不動。要修的是**有**下一批時的判定（見引擎缺口） |
| 同檔 `_assert_round_not_frozen_for_workflow()`（`:155`） | 輪次凍結後不可退回任務（目前只擋 `revert_job`，`:794`） | 新增任務要不要受同一道 gate 擋，是 **D1** |
| `app/flow_control/service/prep_job_generation_service.py:101` `generate()` | 專案啟動時：每個查核項目 clone 一份流程範本 → 建一個流程實例 → 建**一筆**任務，節點編號取 `_first_user_task_id()`（`:93`，正規表示式抓**第一個** `userTask` 的 id，`:54`），再 `inject_job_execution_uid()` 把任務編號注回圖 | ⚠️ 「只抓第一個」在單任務範本下正確；若日後範本本身就是多任務，這裡會漏建。本案不改（範本仍是單任務），但要記進風險 R5 |
| `app/flow_engine/service/stage_advance_service.py` | 輪次推進用 `_peek_next_stage_code_via_jobs()`（`:463`）＋`_is_terminal_user_task()`（`:508`）判下一階段 | **不在本案範圍**——它走的是輪次主流程的圖，不是查核項目底下的收證據流程。要在驗收時確認兩者確實不共用同一張圖 |
| `app/flow_engine/service/stage_rollback_service.py:183` `_find_job_by_stage_code()` | 靠節點上的階段代碼找任務，與任務數無關 | 同上，不在範圍 |
| `app/flow_control/service/job_batch_complete_service.py:86` | 批次完成：逐筆取 `template_job_id` 呼叫 `complete_job()` | **不改程式，但是風險集中點**——批次完成會連續觸發引擎缺口，見風險 R1 |
| `infra/readmodel/tasks/vw_user_job_queue.py` | 任務佇列讀模型，只是把 `template_job_id` 當一般欄位存著（`:77`），沒有順序假設 | 不受影響 |

### 共用套件（jedi-flow-engine，現版 1.1.2）

| 元件 | 現況（實查） | 本案動作 |
|---|---|---|
| `bpmn_uilts.py:276` `get_next_jobs()` | 走訪下一批節點：遇到任務直接收；遇到**互斥閘道**依條件挑一條（比對不中走預設並發 warning）；遇到**平行閘道**（`:326`）把所有出線對應的任務全部收進來 | **平行展開已支援**，不必改 |
| `bpmn_uilts.py:213` `get_next_elements()` | 只看一條出線的目標節點是什麼類型（任務／互斥閘道／平行閘道／子流程） | 不改 |
| `bpmn_uilts.py:501` `append_user_task_to_xml()` | 靜態方法，在**結束事件之前**插一個新方塊——docstring 明寫「接線邏輯（線性流程）」，是把 `最後節點 → 結束` 改成 `最後節點 → 新方塊 → 結束` | 🔴 **這支只會串成一條龍**，本案要的是掛平行分支 → 需新增函式（見 D2） |
| `bpmn_uilts.py:574` `inject_job_execution_uid()` | 把任務編號寫進指定節點的 camunda 屬性，已存在就覆蓋 | 直接沿用 |
| `bpmn_uilts.py:613` `remove_user_task_from_xml()` | 移除節點並把線接回去（`前 → 節點 → 後` 變成 `前 → 後`） | 沿用；但平行分支被刪到剩一條時要不要把閘道也收掉，是新增的邏輯 |
| `bpmn_generator.py:145` `create_bpmn_with_user_tasks()` | **已經能產標準平行結構**：開始 → 分叉閘道 → N 個方塊並排 → 合流閘道 → 結束（`:216` 起逐一建方塊與連線） | 可直接當「重排成標準平行」的參考實作或直接重用 |
| `bpmn_uilts.py:391` `get_job_execution_order()` | 深度優先走訪出順序 | 平行結構下「順序」意義不大，但不會壞 |
| `bpmn_uilts.py:447` `get_jobs_by_layer()` | 廣度優先分層 | 同上 |

### 前端（compliance-manager-fe）

| 元件 | 現況（實查） | 本案動作 |
|---|---|---|
| `ProjectPlanningView.vue:2931` 起 任務區塊 | 逐筆展開面板，欄位齊全；空狀態只顯示「沒有任務」文案，**沒有新增按鈕**（`:2943`） | 🔴 補「新增任務」；空狀態改成可以從這裡開第一筆 |
| `ProjectPlanningView.vue:1772` `saveTask()` | 存單筆任務，含名稱必填、檢測工具必選、逐列參數必填、重複分派檢查 | 不動；新增任務走同一支存檔 |
| `ProjectPlanningView.vue:1923` `openBpmnEditor()` | 開新分頁到 `/workflow/workflow-setup`，帶範本編號／流程實例編號／專案編號／查核項目編號 | 保留；按鈕文案可改成「編輯流程走向」以符合新定位 |
| `WorkflowSetupEditor.vue:402` `onSaveClick()` | 存 XML → `syncJobExecutions()` → 有新任務就 `patchJobExecutionUids()` → **再存一次 XML** → 重新載入圖 | 🔴 砍掉後三步，只留第一步存 XML；新任務由後端在存檔時建 |
| `WorkflowSetupEditor.vue:489` `syncJobExecutions()` | 逐節點打 PUT／POST；失敗會 toast（註解記著 CM-1163 曾經整個吞掉） | 整支移除 |
| `WorkflowSetupEditor.vue:443` `patchJobExecutionUids()` | 用 DOMParser 直接改 XML 字串寫回任務編號 | 整支移除（改由後端回寫） |
| `WorkflowSetupEditor.vue:635` `onSaveTaskProperties()` ＋屬性面板（`:1032-1045`） | 面板有名稱／工作描述／指南／任務類型四欄（另有設備、部門屬性一起寫進圖） | 🔴 只留名稱（見 D4） |
| `TaskSetupView.vue:524` | 也會開 `/workflow/workflow-setup` | 要一起確認改動後行為正常 |
| `FlowTemplateEditorView.vue` | 流程範本管理，是**另一套系統** | 明確不在範圍 |

---

## 目標設計 {#design nav="目標設計"}

### 原則：一件事只有一個地方說了算

| 這件事 | 真相在哪 | 另一邊是什麼 |
|---|---|---|
| 任務叫什麼、描述、指南、類型、負責人、部門、設備、問卷、工具參數、狀態 | **資料庫 `job_executions` ＋關聯表** | 圖上的 `name` 是顯示副本，由後端單向寫；其餘屬性不再寫也不再讀 |
| 誰先誰後、誰跟誰平行、有沒有二選一分支 | **流程圖 XML** | 資料庫不存拓樸 |
| 圖上這個方塊是哪一筆任務 | **圖上的 `job_execution_uid` 屬性**（後端寫） | — |

::: {.callout .crit}
**🔴 判準一句話：圖上的東西只有「拓樸」和「指回任務的編號」是真的，其他都是印出來給人看的。**

任何一次改動只要讓某個欄位「從圖上讀回來當真值用」，就等於把第二份真相請回來。
:::

### 後端新增任務：兩種入口，同一支端點

```{.mermaid cap="圖 1 — 規劃頁新增任務的端到端時序"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
    participant PM as PM（規劃頁）
    participant API as 後端端點<br/>job_route.py:170
    participant SVC as 任務服務<br/>job_service.py:117
    participant REPO as 任務倉儲<br/>repo_impl.py:673
    participant XML as 流程圖改圖<br/>jedi-flow-engine
    participant DB as 資料庫

    PM->>API: 按「新增任務」（只帶名稱，不帶節點編號）
    API->>SVC: create_job(ao_uid, name, job_element_uid=None)
    SVC->>SVC: 檢查是不是專案 manager
    SVC->>REPO: create_job(...)
    Note over REPO: 現況：沒帶編號就 return None<br/>本案：改成往下走
    REPO->>DB: 查這個查核項目的流程範本與流程實例
    REPO->>XML: 依現有拓樸決定插法<br/>（重排成平行 / 掛新分支）
    XML-->>REPO: 新 XML ＋ 新節點編號
    REPO->>DB: 建 job_executions 一筆
    REPO->>XML: inject_job_execution_uid(新節點, 新任務編號)
    REPO->>DB: 寫回範本 XML（主表＋翻譯表）
    REPO-->>SVC: 任務資料
    SVC-->>API: 任務資料
    API-->>PM: 200，任務面板出現、預設展開待填
```

兩種入口走同一支端點，差別只在有沒有帶 `job_element_uid`：

| 入口 | 帶 `job_element_uid` 嗎 | 後端做什麼 |
|---|---|---|
| **規劃頁按「新增任務」** | 不帶 | 自己在圖上插節點、接線、回寫任務編號 |
| **流程圖編輯器拖一個新方塊、存檔** | 帶（前端存 XML 時圖上已有這個節點） | 沿用現況：直接用這個編號建任務、把編號回寫圖上 |

::: {.callout .decided}
**保留第二種入口是決策者明示的**

在圖上拖方塊仍然可以開任務——只是屬性面板只能填名稱，其餘細節回規劃頁補。存檔後後端掃圖，發現沒有對應任務的 `userTask` 就建一筆空任務並回寫編號。
:::

### 插節點規則：看現在的圖長什麼樣決定怎麼插

```{.mermaid cap="圖 2 — 三種情境下新增任務後的拓樸變化"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TB
    subgraph A["情境一：只有一個任務（專案啟動後的預設樣子）"]
      direction LR
      A1([開始]) --> A2[任務 1] --> A3([結束])
      A3 -.新增任務 2.-> A4[" "]
      A5([開始]) --> A6{{分叉}}
      A6 --> A7[任務 1]
      A6 --> A8[任務 2 新]
      A7 --> A9{{合流}}
      A8 --> A9
      A9 --> A10([結束])
    end

    subgraph B["情境二：已是標準平行（再加一個）"]
      direction LR
      B1([開始]) --> B2{{分叉}}
      B2 --> B3[任務 1]
      B2 --> B4[任務 2]
      B3 --> B5{{合流}}
      B4 --> B5
      B5 --> B6([結束])
      B6 -.新增任務 3.-> B7[" "]
      B8{{分叉}} --> B9[任務 1]
      B8 --> B10[任務 2]
      B8 --> B11[任務 3 新]
      B9 --> B12{{合流}}
      B10 --> B12
      B11 --> B12
    end

    subgraph C["情境三：使用者手動排成一條龍（不動他的順序）"]
      direction LR
      C1([開始]) --> C2[任務 1] --> C3[任務 2] --> C4{{合流}} --> C5([結束])
      C4 -.新增任務 3.-> C6[" "]
      C7[任務 1] --> C8[任務 2] --> C9{{合流}}
      C10{{分叉}} --> C11[任務 3 新] --> C9
    end
```

規則寫成白話：

1. **圖上只有一個任務（專案啟動後的預設）** → 重排成標準平行：開始 → 分叉 → 兩個任務並排 → 合流 → 結束。可直接重用 `bpmn_generator.py:145` 的產圖邏輯。
2. **圖上已經是標準平行結構** → 在分叉閘道上多接一條出線、在合流閘道上多接一條入線，新方塊掛中間。
3. **使用者已經在編輯器手動排成一條龍或加了二選一分支** → **不動他排好的順序**，把新任務掛成「合流前的一條新平行分支」。若圖上沒有合流閘道，就在結束事件前補一個。這是 **D2**，需拍板。

::: {.callout .warn}
**為什麼不是「一律重排成標準平行」**

使用者若刻意把任務排成「先做 A 才能做 B」，一按新增就被重排回全平行，等於系統擅自推翻他的設計，而且沒有任何提示。這會是很難解釋的行為。
:::

### 刪除任務：移節點、接線、必要時收掉閘道

規劃頁按刪除 → 後端 `delete_job()`（`repo_impl.py:778`）清關聯表 → 呼叫改圖函式移節點：

- 一般情形：`前 → 節點 → 後` 接回 `前 → 後`（現有 `remove_user_task_from_xml()` 就在做這件事）。
- 平行分支被刪到**只剩一條**時：分叉與合流閘道只剩單進單出，留著不會壞但圖很醜 → 順手把兩個閘道也收掉、接回線性。這是新增邏輯。
- 刪到**一個任務都不剩**：圖變成 `開始 → 結束`。要不要擋（至少留一個任務）屬產品判斷，列在 D6。

### 流程圖存檔時的驗證

編輯器存 XML 時，後端要在寫進資料庫之前檢查三件事，任一不過就退回並指出哪個節點有問題：

| 檢查 | 為什麼 | 不過的症狀（如果不擋） |
|---|---|---|
| 圖上每個 `job_execution_uid` 都查得到對應任務 | 使用者可能複製貼上方塊，連帶複製了別人的任務編號 | 兩個方塊指向同一筆任務，完成其中一個另一個跟著變狀態 |
| 沒有孤兒任務（資料庫有、圖上沒有對應節點） | 使用者在圖上刪了方塊 | 任務永遠停在待辦、永遠不會被推進，而且看不出原因 |
| 每個任務節點從開始事件走得到 | 使用者接線接錯、方塊沒連上 | 完成前一個任務時引擎找不到它，流程卡住 |

孤兒任務的處理方式（刪掉？標成取消？擋下存檔？）需拍板，併入 D6。

### 編輯器鎖定範圍

| 還能做 | 不能做 |
|---|---|
| 改線（把平行拉成一條龍、加二選一分支、調整順序） | 填任務類型、問卷、設備、部門、工具參數（回規劃頁） |
| 拖新方塊進來（只填名稱） | 直接改任務的負責人與狀態 |
| 刪方塊 | — |

屬性面板留哪些欄位是 **D4**。

---

## 🔴 實查發現的引擎缺口 {#engine-gap nav="引擎缺口"}

::: {.callout .crit}
**合流閘道不會等兄弟分支——第一條分支完成，合流後面的任務就被啟動**

實查 `app/flow_engine/service/workflow_execution_service.py:602` `complete_job()`：完成一個任務後拿 `get_next_jobs()`（`:668`）得到下一批，接著在 `:702` 起的 `for job in next_jobs:` 迴圈裡，**把每一個都直接設成 `PROCESSING`**——中間沒有任何「兄弟分支都完成了嗎」的檢查。

**同一個缺口在兩個地方**：另一支 `complete_main_workflow_job()`（同檔 `:469`，輪次推進在用，`stage_advance_service.py:382` 呼叫）的 `:569` 迴圈是一模一樣的寫法。兩處都要修，不能只修一處。

而 `get_next_jobs()`（`jedi-flow-engine/.../bpmn_uilts.py:276`）遇到平行閘道時（`:326`）是把該閘道**所有出線**的任務都收進來——它分不出手上這個閘道是「分叉」還是「合流」，兩者在 BPMN 裡都是 `parallelGateway`，差別只在入線與出線的數量。
:::

**成立條件**：合流閘道後面**還有** `userTask`。以本案要產的標準結構（分叉 → N 個任務 → 合流 → 結束）來說，合流後面是結束事件、`get_next_jobs()` 回空，走的是 `:686` 那條「檢查整條流程還有沒有未完成任務」的分支——**那條是對的**，全完成才標 `COMPLETED`。所以**本案的預設結構不會立刻踩到**。

**但本案會放大它**：

1. 決策者明示編輯器保留改線能力。使用者只要在合流之後接一個「主管覆核」之類的任務，缺口立刻成立——第一個同仁交完證據，覆核任務就跳成進行中，另外兩個同仁根本還沒開始做。
2. 批次完成（`job_batch_complete_service.py:86`）會連續呼叫 `complete_job()`，一次完成多個平行任務，把這個時序放到最大。
3. 現在之所以沒人回報，是因為**現況每個查核項目只有一個任務**（`prep_job_generation_service.py:170` 只抓第一個 `userTask` 建一筆），平行結構幾乎不存在，缺口沒有被觸發的機會。

要不要在本案一併修，是 **D3**。

---

## 待決策 {#decisions nav="待決策"}

::: {.callout .pending}
**D1 — 流程啟動後、輪次凍結了，還能不能新增任務？**

現在的凍結 gate（`workflow_execution_service.py:155` `_assert_round_not_frozen_for_workflow`）只擋「退回任務」這一個動作（`:794`），新增任務完全沒有經過它。

要考慮的是：稽核輪次一旦離開規劃期（`launch_audit` 之後），任務清單就是這一輪的稽核範圍；中途追加任務等於改變已經在跑的稽核範圍，證據完整性與報表都會受影響。但實務上「稽核到一半發現漏了一項」也確實會發生。

[**建議**：本期**不可以**——新增任務只在規劃期開放，凍結後按鈕停用並顯示原因（照 FR-110 的做法，停用＋留住提示，不彈 toast）。「稽核中補任務」牽涉補件流程與報表口徑，另開一期處理。]{.rec}
:::

::: {.callout .pending}
**D2 — 使用者已經把流程排成一條龍，這時在規劃頁新增任務，要掛哪裡？**

三個選項：①一律重排成標準平行（推翻使用者排的順序）；②掛在最後一個任務後面、繼續串成一條龍（符合「接在後面」的直覺，但與「新增預設平行」的拍板方向相反）；③在合流前掛一條新的平行分支（保留使用者排好的順序，新任務與那整條串並行）。

[**建議**：選③。理由是它同時滿足兩件事——不動使用者刻意排的順序，又維持「新增的任務預設不相依」這個拍板原則。圖上沒有合流閘道時，就在結束事件前補一個。]{.rec}
:::

::: {.callout .pending}
**D3 — 合流閘道等待修正要不要納入本案？**

缺口本身是既有的，不是本案造成的。但本案讓平行結構從「幾乎不存在」變成「預設樣貌」，並且明示保留改線能力——不修的話，使用者只要在合流後接一個覆核任務就會踩到，而症狀（覆核任務提早跳成進行中）看起來像是資料錯亂，不像流程設定問題，會很難查。

修法方向：`complete_job()` 把下一批任務設為進行中之前，先判斷這個節點的入線是不是來自合流閘道；是的話檢查該閘道所有入線分支的任務是否都已完成／取消，沒有就跳過不啟動。判斷邏輯適合放在 jedi-flow-engine（那裡才看得到拓樸）。

[**建議**：納入，排在階段①（流程圖改圖工具）一起做。它與改圖函式同屬套件層、同一個人一棒做完最省；分開做則後面那一棒要重新把整張圖的拓樸語意讀一遍。]{.rec}
:::

::: {.callout .pending}
**D4 — 流程圖編輯器的屬性面板留哪些欄位？**

現在面板有名稱、工作描述、指南、任務類型四欄（`WorkflowSetupEditor.vue:1032-1045`），另外還把設備與部門寫進圖的 camunda 屬性。決策者裁定「只留名稱」。

要確認的是：工作描述與指南要不要一起留？留著的好處是在圖上就看得到這個方塊在做什麼；壞處是它們又變成第二份真相，規劃頁改了描述、圖上沒改，兩邊不一致。

[**建議**：**只留名稱**，照拍板執行。描述與指南從面板拿掉，圖上既有的屬性讀取路徑一併停用。理由是「在圖上看得到描述」這個好處，可以靠後端單向把名稱寫得更完整達成，不值得為它保留一條雙向寫入路徑。]{.rec}
:::

::: {.callout .pending}
**D5 — 既有專案的圖上帶著完整的 camunda 屬性，要不要清掉？**

現有專案的流程圖 XML 裡已經有 `workDescription` / `guide` / `actionType` / `actionInfo` / `devices` / `departments` / `toolParams` / `completionMode` 這些屬性（`repo_impl.py:401` 一路寫進去的）。

選項：①寫 migration 掃全部範本清掉這些屬性；②不清，改成讀的時候一律忽略。

[**建議**：**不清、讀時忽略**。理由有三：一是清除 migration 要改動所有租戶的範本 XML，改壞了沒有回頭路（XML 沒有欄位層級的版本控制）；二是這些屬性留著不會造成行為差異——只要沒有任何程式碼讀它，它就只是一段被忽略的文字；三是清除的唯一好處是「看起來乾淨」，不值得承擔改壞既有專案流程的風險。**但 `_sync_task_to_template_xml()` 要收斂成只寫 `name`**，否則舊屬性會被持續刷新、永遠不會自然凋零。]{.rec}
:::

::: {.callout .pending}
**D6 — 邊界情形怎麼處理？**

三個小問題併成一項：①一個查核項目可不可以一個任務都沒有（刪到空）；②存檔時發現孤兒任務（資料庫有、圖上沒有）要刪掉、標取消還是擋下存檔；③一個查核項目最多幾個任務（要不要設上限）。

[**建議**：①**可以是零**，規劃頁空狀態本來就已經有畫面了，強制留一個反而會讓「這項不需要做」無法表達；②**擋下存檔**並指出是哪一筆任務，不自動刪——自動刪掉使用者已經填了半天的任務是不可逆的，而擋下來只是多按一次；③**不設上限**，但超過一定數量（例如 20）在規劃頁給提示，因為平行分支太多在圖上會擠成一團、難以閱讀。]{.rec}
:::

---

## 影響面與風險 {#risks nav="影響面與風險"}

| 編號 | 風險 | 實查結果 | 嚴重度 | 怎麼處理 |
|---|---|---|---|---|
| **R1** | 合流閘道不等兄弟分支（見上節） | ✅ **成立**，`workflow_execution_service.py:702` 與 `:569` 兩處迴圈皆無等待檢查；`bpmn_uilts.py:326` 平行閘道無條件展開全部出線 | 高（本案放大） | 見 D3 |
| **R2** | 批次完成連續觸發引擎缺口 | ✅ `job_batch_complete_service.py:86` 逐筆呼叫 `complete_job()`，中間沒有彙總判定 | 中（依附 R1） | R1 修好即消失；驗收時要專門測「一次完成多個平行任務」 |
| **R3** | 新任務初始狀態判定在平行結構下不對 | ✅ `repo_impl.py:750`：該流程下沒有未完成任務 → 新任務開 `PROCESSING`、否則 `TODO`。平行結構下所有分支**應該都可以開始做**，但依現行邏輯第二個之後建的都會是 `TODO`（排隊） | 中 | 平行分支上的任務建立時應一律 `PROCESSING`；判定要改成看「這個節點的前一個節點是不是分叉閘道／開始事件」 |
| **R4** | 「可不可以退回」判定只看下一批的第 0 筆 | ✅ `repo_impl.py:394`：`next_jobs[0]` 硬取第一筆；另 `:375` 有 `len(all_jobs)==1 → True` 的捷徑，多任務後這條捷徑失效 | 中 | 平行結構下要改成「所有下一批任務都還沒開始才可以退回」；同時 `len(all_jobs)==1` 那條捷徑要重新檢視 |
| **R5** | 專案啟動只建第一個任務 | ✅ `prep_job_generation_service.py:93` `_first_user_task_id()` 用正規表示式抓**第一個** `userTask`（`:54`），每個查核項目只建一筆任務 | 低（本案不改範本） | 本案範本仍是單任務，不受影響。但若日後把範本本身做成多任務，這裡會靜默漏建——**要在該函式旁留一句註解說明這個前提** |
| **R6** | 輪次推進／回退走的是另一張圖，可能誤判成受影響 | ⚠️ `stage_advance_service.py:85` 與 `stage_rollback_service.py:144` 都在讀 `workflow_template_xml`，需**驗收時實查確認**它們讀的是輪次主流程的圖、不是查核項目底下的收證據流程 | 待驗證 | 列為階段⑤的必測項；若真的共用同一張圖，本案範圍要重新評估 |
| **R7** | 範本 XML 是可翻譯欄位，只寫主表會讓編輯器讀到舊值 | ✅ `repo_impl.py:495` 起的檔內註解記著這個坑：規劃頁走原生 SQL 讀**主表**、編輯器走倉儲的語系備援讀**翻譯表**，只更新主表會讓編輯器永遠看到舊圖 | 高（已知坑） | 後端所有寫圖路徑（新增插節點、刪除移節點）都必須比照現況**同時寫主表與翻譯表** |
| **R8** | 前端移除兩次存檔後，圖與任務短暫不一致 | 現況 `onSaveClick()` 存兩次 XML 是為了把新任務編號寫回圖 | 低 | 改成後端在同一個交易內完成（插節點＋建任務＋回寫編號＋寫圖），前端只存一次、回傳新 XML 讓前端重新載入 |
| **R9** | 檢測工具型任務的參數加密路徑 | `job_service.py:163` 起的註解寫「新建路徑不經倉儲的 BPMN 回寫，但仍必須加密」 | 低 | 新增任務改走同一支 `create_job()`，加密路徑不變；但要驗收「規劃頁新增的檢測工具任務，參數確實有加密落庫」 |
| **R10** | 套件改動會影響其他使用者 | jedi-flow-engine 目前版本 1.1.2，是共用套件 | 中 | 依規範：改動範圍與其他使用者風險要在實作計畫中明列、等決策者點頭；開發期走 poetry 路徑相依，發版另外等指示 |

---

## 階段拆分草案 {#phases nav="階段拆分"}

::: {.callout .warn}
**這是草案，不是派工單**

各階段的驗收條件寫在下面，但實際開卡、切棒、派工要等決策者發令。跨 3 repo，各 repo 分開 commit。
:::

### 階段① — 流程圖改圖工具（jedi-flow-engine）

| 項目 | 內容 |
|---|---|
| **做什麼** | ①新增「把流程重排成標準平行」函式（可重用 `bpmn_generator.py:145` 的產圖邏輯）；②新增「在合流閘道前掛一條新分支」函式；③擴充 `remove_user_task_from_xml()`，平行分支剩一條時把閘道收掉；④修 `get_next_jobs()` 或在其上補一層合流等待判定（依 D3 定案）；⑤全部補單元測試 |
| **不做什麼** | 不動 `inject_job_execution_uid()`、不動互斥閘道的條件判定邏輯 |
| **驗收條件** | 1. 單一任務的圖 → 呼叫重排函式 → 產出「開始→分叉→2任務→合流→結束」且兩個任務的編號都保留<br/>2. 標準平行圖 → 掛新分支 → 分叉出線數與合流入線數都 +1<br/>3. 三分支平行圖 → 刪到剩一條 → 閘道消失、接回線性<br/>4. **合流後還有任務的圖**：完成第一條分支 → 合流後的任務**不得**變進行中；兩條都完成 → 才變進行中 |
| **交付** | 套件原始碼改動 ＋ 測試；**不發版**（開發期走路徑相依） |

### 階段② — 後端新增／刪除與存檔驗證（compliance-manager-be）

| 項目 | 內容 |
|---|---|
| **做什麼** | ①`repo_impl.py:698` 的 `if not job_element_uid: return None` 改成走插節點路徑；②依 D2 定案實作插法判斷；③刪除任務走 `delete_job()` 移節點；④`_sync_task_to_template_xml()` 依 D4／D5 收斂寫入欄位；⑤流程圖存檔時加三項驗證；⑥修 R3（初始狀態）與 R4（可否退回）；⑦依 D1 加凍結 gate |
| **不做什麼** | 不動守門（`_require_manager`）、不動問卷 snapshot／工具綁定／加密路徑、不動輪次推進與回退 |
| **驗收條件** | 1. Swagger 打新增任務端點**不帶** `job_element_uid` → 200，資料庫查該範本 XML 多一個方塊且帶新任務編號<br/>2. 同一個查核項目連續新增三次 → 圖是「分叉→3任務→合流」，三筆任務都是進行中（R3）<br/>3. 刪除其中一筆 → 圖剩兩條分支，任務與五張關聯表都清乾淨<br/>4. 刪到剩一筆 → 閘道收掉、接回線性<br/>5. **XML 主表與翻譯表都更新**（R7）：改完打開編輯器看得到新方塊<br/>6. 輪次凍結後打新增端點 → 依 D1 定案回應 |
| **交付** | BE 改動 ＋ 走 `ddd-compliance-reviewer` 掃 diff |

### 階段③ — 規劃頁新增／刪除按鈕（compliance-manager-fe）

| 項目 | 內容 |
|---|---|
| **做什麼** | ①任務區塊補「新增任務」（空狀態與有任務時都要有）；②每筆任務面板補刪除（帶確認）；③新任務建立後自動展開、聚焦名稱欄；④凍結狀態按鈕停用＋留住提示（照 FR-110 做法） |
| **不做什麼** | 不動 `saveTask()`、不動檢測工具參數的逐列驗證 |
| **驗收條件** | 1. 規劃頁點查核項目 → 「新增任務」→ 填名稱存檔 → 重新整理仍在<br/>2. 打開流程圖編輯器 → 新方塊與原任務**並排**（平行）不是串在後面<br/>3. 刪除任務 → 確認後消失，重新整理不會回來<br/>4. 凍結狀態下按鈕停用且**看得到原因**（不是無聲失效） |

### 階段④ — 流程圖編輯器瘦身（compliance-manager-fe）

| 項目 | 內容 |
|---|---|
| **做什麼** | ①`onSaveClick()` 砍成只存一次 XML；②移除 `syncJobExecutions()` 與 `patchJobExecutionUids()`；③屬性面板依 D4 只留名稱；④存檔失敗時顯示後端回的驗證訊息（哪個節點有問題）；⑤`TaskSetupView.vue:524` 進來的路徑一併驗 |
| **不做什麼** | 不動 `FlowTemplateEditorView.vue`（另一套系統） |
| **驗收條件** | 1. 點方塊 → 右邊只剩名稱欄<br/>2. 拖新方塊存檔 → 回規劃頁看得到一筆新的空任務，名稱是圖上打的那個<br/>3. 刪方塊存檔 → 規劃頁對應任務消失<br/>4. 故意製造孤兒（改 XML 刪掉某方塊但任務還在）→ 存檔被擋下且**指得出是哪一筆** |

### 階段⑤ — 驗收與回歸（compliance-manager-test）

| 項目 | 內容 |
|---|---|
| **做什麼** | 四條既有動線在平行結構下重跑：專案啟動建任務、輪次推進、批次完成、任務佇列；補 e2e 情境 |
| **必測** | 1. 起新專案 → 規劃頁加兩個任務 → **只完成一個**時該查核項目**不可以**變已完成；兩個都完成才可以<br/>2. 批次完成一次勾兩個平行任務 → 狀態正確，不出現提早啟動（R2）<br/>3. **R6 實查**：確認輪次推進／回退讀的圖與查核項目收證據流程的圖不是同一張<br/>4. 檢測工具型任務從規劃頁新增 → 參數確實加密落庫（R9） |

---

## 這份文件的定位 {#positioning nav="定位"}

| 你想知道 | 看哪份 |
|---|---|
| 為什麼要做、決策者裁了什麼、哪些還沒決定 | **本文件** |
| 過去診斷雙向同步缺口的原始爬梳 | `docs/analysis/2026-06-17-ao-statement-key-mismatch-and-task-bpmn-sync.md` 第二節 |
| 流程圖屬性與 API 欄位的對照 | FE `docs/guides/grc-page-data-models.md:40-47` |
| 拍板後的完整設計與取捨 | `design.md`（待寫，D1–D6 定案後） |
| 每一棒做了什麼 | `handoff/FR-112-LOG.md`（待建） |
