# FR-049 問卷流程規則（跳題）+ 子題 Designer 接線 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 復刻 v1 跳題語意到 v2：BE（jedi-survey）持久化 flow rules → Designer 接線讀寫 → 填答端可見性引擎（純函數）驅動渲染/進度/必填檢核三消費點 → 稽核檢視端區分「不適用」與「漏答」；順手修復 D-3a 子題 Designer 接線斷鏈。

**Architecture:** 規則內嵌在 `SurveyQuestion.options` JSONB 每個選項物件（`goto_question_uid` / `goto_page_uids` 新鍵），無 schema migration。填答端用**單一純函數** `computeVisibility(allQuestions, allPages, flowRuleSourceQuestions, answers) → { visibleQuestionIds: Set, visiblePageIds: Set }`，答案任何變動即全量重算；此函數是 SurveyPreview.vue 的唯一可見性入口，`currentQuestions`／`totalRequired`／`onSave` 必填檢核三處全部改吃它的輸出，不得各算各的。

**Tech Stack:** Python (Flask + Marshmallow + SQLAlchemy, jedi-survey 套件 path dependency dev loop) / Vue 3 Composition API + PrimeVue（compliance-manager-fe）/ pytest（BE 單元測試）/ Cucumber.js + Playwright（compliance-manager-test E2E）。

**✅ 三個阻塞性決策點——指揮官均已裁決（2026-07-18，原記錄於 commit `3dfdd775` message 與 post-v182 handoff §1.3，本節為正式落文），執行者依下列結論直接開工，不需再停下等待**：
1. **§0-a-2 / Task 1-4**：維持 plan 對策原文執行——Task 1-4 先做 DEV 翻譯路徑實測（此為執行步驟非待裁決項）；若確認規則落翻譯表，填答端所有讀 `options` 的 `get_questions()` 路徑（checkpoint 三處 + Task 4-1 快照讀取）補傳 `locale` 走 failover。
2. **Task 4-1**：**採方案 X**——快照掛每筆 `TaskSurveyAnswerHistory`，新增 JSONB 欄位走 `sql-migration` skill 鐵則寫 migration（寫好先不套，套用時機回報指揮官）。
3. **Task 3-1 Step 1-b**：**採 OR 寬鬆語意**（多規則指向同一題組時，任一規則的當前選中結果要求顯示即顯示），Step 1 草案維持原樣，補 Step 1-c 測試後隨 Task 3-5 commit。

---

## 0. 前提查證（開工前 live 驗證結果）

依 CLAUDE.md「plan 假設先 verify 才開工」與本次派工的五項要求，逐項在 DEV 環境對照原始碼查證（2026-07-18）：

### a. jedi-survey options 讀寫鏈 — passthrough，新鍵不會被剝掉 ✅（design 假設成立）

- BE serializer `api/survey/serializers/survey_question.py`：`SurveyQuestionCreateRequest`/`UpdateRequest` 的 `options = fields.List(fields.Dict(), ...)` —— `fields.Dict()` 對每個選項物件的 key 是**開放型別**（無 `Schema` 白名單），任意新鍵原樣通過 marshmallow `.load()`。
- `SurveyQuestionResponse.options = fields.List(fields.Dict())` 同理，dump 時原樣吐回，新鍵不會被剝。
- jedi-survey `SurveyQuestionEntity.options`（`domain/entities/survey_question_entity.py`）是 `Optional[list[Dict[str, Any]]]`，`SurveyQuestionMapper`（`infra/mapper/survey_question_mapper.py`）與 ORM `SurveyQuestion.options`（`infra/models/survey_question.py`，`Mapped[Optional[Any]] = mapped_column(JSONB, ...)`）全程原樣搬運，無欄位級白名單。
- 全問卷整棵樹 PUT 路徑（`SurveyRequest.pages = fields.List(fields.Dict())`，`api/survey/serializers/survey.py`）同樣是開放 dict，`survey_page_domain_service.update_page()` 對 `question["options"]` 直接塞進 `SurveyQuestionEntity(**question)`，一路到 repo `.update()`／`.add()`，中間沒有任何步驟做 key 過濾。
- **結論**：新鍵 `goto_question_uid`/`goto_page_uids` 可以直接放進 options dict 存讀，**BE serializer 不需要改就能存**——但為了語意驗證（單選限定、目標存在性、禁止自指），仍需在 jedi-survey **domain service 層**加驗證邏輯（見 §1），不能只靠序列化層的「開放通過」。

### a-2. ⚠️ options 是可翻譯欄位——條件式雙寫路徑對 flow_rules 讀寫一致性的風險（本次 review 補查）

§0-a 只驗證了序列化層／mapper 層的「欄位級透傳」，**遺漏了 options 走可翻譯欄位條件式雙寫路徑的影響**，經 code review 補查如下：

- `jedi_survey/infra/mapper/survey_question_mapper.py` 的 `translatable_fields` 屬性含 `'options'`；`SurveyQuestionRepoImpl.__init__()` 的 `_setup_translation(translatable_fields=['question', 'description', 'options'], ...)` 同樣把 `options` 列為可翻譯欄位。
- jedi-common `BaseRepositoryImpl.update()`（`update_with_translation()`）行為是**條件式雙寫**：
  - 該 locale 的翻譯記錄**已存在** → 只更新翻譯表的 `options`，**主表 `options` 不動**。
  - 該 locale 的翻譯記錄**不存在** → 新增翻譯記錄，**同時**把主表 `options` 也更新成同一份值。
- 主專案 `api/survey/routes/survey_question_route.py` PUT 呼叫 `update_question(existing, locale)`，`locale = str(get_locale())`（通常是 `zh_Hant_TW`）——代表**存規則走的是有 locale 的路徑**，若該題目已有 `zh_Hant_TW` 翻譯記錄，flow rule 只會寫進翻譯表，主表 `options` 停留在舊版本（無 goto 鍵）。
- **關鍵風險**：`checkpoint_task_survey_answer()`（`app/task_survey/service/question_answer_service.py:77/210/354`）呼叫 `self.survey_question_domain_service.get_questions(survey_id=...)` **完全沒有傳 `locale`**——查證 `SurveyQuestionDomainService.get_questions()` 簽章 `locale: str = None`，未傳 locale 時 `get_all_by_fields()` 直接走「標準查詢模式」（`self.session.query(self.model)`），**讀的是主表原始 `options`，不會 failover 到翻譯表**。也就是說：**Designer 存規則可能只落進翻譯表，但填答端／checkpoint 路徑讀的是主表——規則存進去但填答不生效，正是 design.md 開篇要避免的坑**。
- **對策（納入 §2 Task 1-4 追加驗證 + 段③/段④ BE 讀取路徑要求）**：
  1. 開工前（Task 1-4 內追加一步）先在 DEV 環境對一題**已有 zh_Hant_TW 翻譯記錄**的既有題目實測 PUT 帶 goto 鍵，重啟後直接查 `survey.survey_questions` 主表 `options` 欄位，確認 goto 鍵是否真的落進主表。
  2. 若確認落進翻譯表而非主表（依上述程式碼推斷應該會），**填答端所有讀 `options` 做可見性判斷的路徑（`get_questions()` 呼叫）都必須補上 `locale` 參數**，確保走 failover 查詢讀到含新鍵的版本。這包含：`checkpoint_task_survey_answer()` 的三處 `get_questions()` 呼叫、Task 4-1 的稽核檢視快照讀取路徑。
  3. 若步驟 1 顯示新建立的題目（尚無翻譯記錄）行為正常但既有題目（已有翻譯記錄）異常，代表本問題只影響「對舊資料補設規則」的情境，仍需在測試案例中覆蓋（Task 1-2 的正向案例補一條「對已有翻譯記錄的既有題目更新 options」的案例）。
  - **此項為新發現的阻塞性查證缺口，Task 1-4 執行順序需調整為：先做這裡的翻譯路徑實測，若發現規則真的只落翻譯表，段③④所有讀 `options` 的呼叫都要重新檢查是否漏帶 `locale`，再繼續往下走。**

**Task 1-4 DEV 實測結果（2026-07-18，執行者實測 + 指揮官獨立核實）— 與本節推論相反，找到真正 root cause**：

對題目 `id=8521`（`uid=c66394ac-db3e-433e-90fd-706c2fdcab86`，已有 `zh_Hant_TW` 翻譯記錄）PUT 帶 `goto_question_uid`，直查 DB 發現**主表與翻譯表同時寫入 goto 鍵**（測試完畢已清理復原）：
```
主表 survey.survey_questions.options：      [..., "goto_question_uid": "34ea..."]  ← 有寫入
翻譯表 survey.survey_questions_trans.options：[..., "goto_question_uid": "34ea..."]  ← 也有寫入
```

**Root cause 追到 `jedi_common` 套件**（`jedi_common/session/database/repository/base_repository_impl.py:628-633`，`update_with_translation()`）的**既有 bug**（超出本次 jedi-survey 異動範圍，非 FR-049 引入）：

```python
non_translatable_fields = [
    field.key for field in mapper.column_attrs
    if field not in self._translatable_fields   # BUG：field 是 ColumnProperty 物件，
                                                   # self._translatable_fields 是字串 list，
                                                   # 兩者永遠不相等（正確寫法應為 field.key not in ...）
       and hasattr(model_instance, field.key)
       and field.key not in update_exclude_fields
]
```

型別比對錯誤導致「排除可翻譯欄位」邏輯從未生效——`options` 明明是可翻譯欄位，卻被算進 `non_translatable_fields`，每次 `update()` 都會**無條件把 entity 的 options 值重寫回主表，不管翻譯記錄存不存在**。§0-a-2 上方推論的「條件式雙寫」（翻譯記錄存在時主表不動）**在目前程式碼行為下不成立**——實際上主表永遠同步。

**⚠️ 裁決與耦合警告（指揮官拍板，2026-07-18）**：jedi_common 此 bug **本次不修**（純記錄；影響面盤點需涵蓋 ModuleFrame 等其他可翻譯欄位 consumer，不塞進本次 FR-049 範圍）。**FR-049 段③/段④讀 `get_questions()` 不需要補 `locale` 參數**（`checkpoint_task_survey_answer()` 三處呼叫、Task 4-1 快照讀取路徑皆維持現狀）——但這個「不補 locale」的結論**依賴 jedi_common 這個既有 bug 的副作用**（主表恆被同步覆寫）。**明確耦合關係記錄於此**：未來若有 session 修復 jedi_common `update_with_translation()` 的這個型別比對錯誤，有翻譯記錄的題目主表將不再被同步更新，`flow_rules` 主表讀取路徑會斷——**修復 jedi_common 此 bug 的 session 必須連帶回歸 FR-049 跳題功能**（屆時 E2E 場景已在 test repo，可作為回歸網）。

### b. 「v2 問卷發布後結構鎖定不可改」— ❌ **前提不成立，design.md 假設與現實不符**

依 design.md §2 指示「此前提 implementation plan 時驗證，若發布後仍可改結構則改為提交時落 snapshot flag」——查證結果：**確實不成立，啟用 snapshot flag 方案**。

證據：
- `jedi-survey` `SurveyEntity.release`（`domain/entities/survey_entity.py`）只是一個 int flag（0/1），`domain/service/survey_domain_service.py` 的 `update_survey()`／`SurveyPageDomainService.update_page()`／`SurveyQuestionDomainService.update_question()` 全部**沒有任何 `if survey.release == 1: raise ...` 或類似守門**。
- 主專案 route 層（`api/survey/routes/survey_route.py` PUT `/survey/:uid`、`api/survey/routes/survey_question_route.py` PUT `/survey-question/:uid`）同樣沒有 release 狀態檢查。
- FE `SurveyDesigner.vue` 沒有 `isReleased`/`canEdit`/`readonly` 之類的欄位鎖定邏輯（grep 全檔案零命中），已發布問卷在 Designer 一樣可以編輯題目/選項/結構並存檔（`onSave()` 直接呼叫 `updateSurvey()`，沒有狀態分支）。
- **代表**：已發布問卷事後仍可改結構（包含改 flow rules），會有「填答當下的規則」與「事後被改的規則」漂移的風險——design.md 已預見此情境並指定 fallback。

**採用方案**：稽核檢視端重算不適用/漏答時，**不即時拉當前 `SurveyQuestion.options` 規則**，而是在 `checkpoint_task_survey_answer`（`app/task_survey/service/question_answer_service.py`）每次落地答案時，把**當下讀到的 flow_rules 快照**（從 `q_uid_to_obj` 已載入的 options 抽取 goto 鍵）連同 `TaskSurveyAnswerHistory` 一起落地一份 `flow_rules_snapshot` JSONB（掛在 `TaskSurvey` 或每筆 checkpoint 對應的 history record，取決於既有 schema，見 §3 Task 3-1 的落點抉擇）。稽核檢視重算不適用集合時，讀這份快照而非即時問卷結構，避免「規則被事後改過」造成歷史畫面漂移。
　　　→ **本項屬於 design.md 未拍板的新增資料落點，implementation 開始前必須讓指揮官確認 snapshot 掛在哪張表（見 §3 Task 3-1 的兩個選項），不可由執行者自行拍板。**

### c. FE SurveyPreview.vue 現行可見性/進度/必填三處位置 — 已定位，皆為單一小面積改點 ✅

- `allQuestions`（`SurveyPreview.vue:585`）= `collectAllQuestions(survey.value.pages)`，遞迴走全部 pages/questions/subQuestions，**目前無任何可見性過濾**。
- `totalRequired`（`:590`）= `allQuestions.value.filter(q => q.isRequired).length` —— 全量掃描，未排除被跳過題目。
- `progress`／`answeredCount`（`:592-599`）、`progressItems`（`:601-623`）同樣全量掃描 `allQuestions`／`allPagesFlat`。
- 必填檢核（提交/關閉守門）在 `onSave()`（`:744-766`）：`missing = allQuestions.value.filter(q => q.isRequired && !isAnswered(q.id))`——**目前唯一的必填檢核入口，且是 FE-only**（BE `checkpoint_task_survey_answer` 完全沒有 required 檢查，純粹信任 FE 送來的 status transition）。
- `currentQuestions`（`:563-567`）決定當前頁面渲染哪些題目，**尚未過濾**。
- **插入點**：新增一個 computed `visibility = computed(() => computeVisibility(allQuestions.value, allPagesFlat.value, answers.value))`，回傳 `{ visibleQuestionIds: Set, visiblePageIds: Set }`。`allQuestions`／`totalRequired`／`currentQuestions`／`onSave()` 的 missing 檢查、`progressItems` 全部改用這個 computed 的輸出做交集過濾（**必填檢核與進度計算同源**，design.md §2 定案的核心要求）。三入口（`/survey/preview/:id` 預覽、`/survey/fill` 任務填答、`review`/`history` 模式）都掛同一個 `SurveyPreview.vue`，改一處即全生效（design.md §5 已確認的路由盤點）。

### d. Designer flowRules 殼現行資料形狀 vs design 新鍵差距 — 已定位，需三處改動

- `AddQuestionDialog.vue` 內部狀態 `form.flowRules` 現行形狀：`{ optionId, optionLabel, targetQuestionId, targetQuestionLabel }`（`onSubmit()`，`:206-222`）——**單目標題**（`targetQuestionId` 是 string，非陣列），對應 design.md 的 `goto_question_uid`（同題組內跳題），但**完全沒有「跳題組（多選）」的欄位/UI**（design.md §2 混合情境「答 3 跳 B、C 兩題組」需要的 `goto_page_uids` 陣列）。
- `allQuestionsFlat`（`:121-137`，`collectQuestions()`）只收集題目（`p.questions`），沒有題組（page）清單可供「跳到題組」下拉選——需新增等價的 `allPagesFlat`（用既有 `flattenPages` from `survey-mock-data.js`，已在 `useSurveyApi()` re-export）。
- **UI 需求**：`onSubmit()` 組出的 rule 物件需擴充為區分「跳題」vs「跳題組」兩種目標型態（design.md 資料模型是兩個獨立鍵 `goto_question_uid` / `goto_page_uids`，故 UI 每條規則需要一個「目標型態」切換或分成兩個獨立區塊：單一題目下拉 + 題組 MultiSelect，MultiSelect 元件已在 `main.js` 全域註冊）。
- **落地斷鏈確認**：`onQuestionSubmit()`（`SurveyDesigner.vue:392-440`）create/update payload **完全不帶 `flowRules`**（比對 `apiCreateQuestion`/`apiUpdateQuestion` 呼叫參數列表，只有 `questionNumber/question/helpText/isRequired/weight/type/options`），`useSurveyApi.js` 的 `createQuestion()`/`updateQuestionApi()` 對應也不處理 `flowRules`；`survey-mapper.js` 的 `mapOptionToApi()`（`:204-212`）與 `mapOptionFromApi()`（`:112-123`）**都沒有 goto 鍵的映射**。這與 test repo 既有 `@known-bug` scenario 註記一致（見 §0-e）。
- **接線範圍**：`mapOptionToApi`/`mapOptionFromApi` 各加兩個鍵的映射；`AddQuestionDialog.onSubmit()` 組出的 `flowRules` 改組進對應 `options[].goto_question_uid`/`goto_page_uids`（在儲存前，由 `onQuestionSubmit()` 或新的轉換函式把 dialog 的 flowRules 陣列攤平回各 option 物件內）；`useSurveyApi.createQuestion`/`updateQuestionApi` 的 `options.map()` 補上這兩鍵的透傳。

### e. Designer 刪題/刪題組連動清理 — ❌ 目前無任何清理 hook，需新增

- jedi-survey `SurveyQuestionDomainService.delete_question()`（`domain/service/survey_question_domain_service.py:118-127`）：純粹刪除該筆，**不掃描其他題目的 options 是否有 `goto_question_uid` 指向它**。
- `SurveyPageDomainService.delete_page()`（`domain/service/survey_page_domain_service.py:271+`）：同樣純刪除，**不掃描 `goto_page_uids` 陣列是否含它**。
- FE `onDeleteQuestion()`（`SurveyDesigner.vue:442-463`）、頁面刪除（`AddPageDialog` 觸發路徑）同樣沒有規則清理。
- **對策**（BE 層，jedi-survey domain service 加）：`delete_question(uid)`／`delete_page(uid)` 執行刪除前，先掃描同問卷所有題目的 `options[].goto_question_uid == uid` 或 `uid in options[].goto_page_uids`，命中則從該 option 物件移除該鍵（不整條刪 option，只清規則）並連同該題一併 `update()`。此為新增邏輯，非既有 hook 可掛，需完整新寫（design.md §5「規則循環」風險段已提示要在 Designer 提示矛盾規則，但**刪除孤兒規則的資料完整性清理是本查證新發現的必要範圍**，design.md 未明確提及，補入 plan）。

---

## 1. 範圍與依賴順序

四段鏈依序開發，**每段完成後必須先驗證再進下一段**（BE 落地驗證 → Designer 存讀驗證 → 填答端行為驗證 → 稽核檢視驗證），因為填答端依賴 Designer 能正確寫入規則，Designer 依賴 BE 能正確持久化。D-3a 子題接線與 flow rules 接線共用同一批檔案（`AddQuestionDialog.vue`／`survey-mapper.js`／`SurveyDesigner.vue`），故安排在同一個 Task 一併处理，避免對同一批檔案改兩次。

**Not-in-scope（design.md §5 已列 + 本次查證新增）：**
- Excel 匯入問卷（`import_survey`）不支援 flow_rules 欄位；匯入的問卷無規則，可後續在 Designer 補設。
- v1 遺留鍵（`goto_question`/`goto_page_1`/`goto_page_2`）不讀不寫不遷移。
- 多選/其他題型不允許設規則（單選限定）。
- **本次新增的 not-in-scope**：跨題組跳題目標若目標題所在題組本身被規則隱藏（規則交叉遮蔽）不做特殊處理——依 design.md 的純函數語意，可見集合是一次算完的定點結果，不特別擋這種設計者自己設出的矛盾規則組合，僅在 Designer UI 提示（文字說明，非強制阻擋）。
- 稽核檢視端「不適用」重算的 snapshot 落點（§0-b）需指揮官在實作前拍板，本 plan 先以「掛在 checkpoint 每次寫入時」為預設方案往下規劃，若指揮官選別的落點需回頭調整 Task 3-1/4-1。

---

## 2. 段①：jedi-survey BE（flow_rules 持久化 + 驗證）

**環境準備**：本段全程在 jedi-survey 套件 path dependency 下開發（CLAUDE.md 外部套件異動規範）。

### Task 1-1: 切換 jedi-survey 為 path dependency

**Files:**
- Modify: `/Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/pyproject.toml:68`（註解掉 pin）
- Modify: `/Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/pyproject.toml:101`（取消 path dependency 註解）

- [ ] **Step 1**: 編輯 `pyproject.toml`，把第 68 行 `"jedi-survey==0.0.26",` 註解掉，第 101 行取消註解改為：
```toml
jedi-survey = { path = "/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-survey", develop = true }
```
- [ ] **Step 2**: 執行 `poetry update jedi-survey`（不可用 `poetry lock`，會卡很久）
- [ ] **Step 3**: 確認 venv 內 jedi_survey 是 symlink 而非 site-packages 拷貝：
```bash
python3 -c "import jedi_survey; print(jedi_survey.__file__)"
```
Expected: 路徑指向 `~/Projects/Jedicogy/module/jedi-python-package/jedi-survey/jedi_survey/...`（非 site-packages）
- [ ] **Step 4**: 不 commit 此步驟（path dependency 改動依規範不 commit，feature 完成後才連同套件發版一起還原 pin）

### Task 1-2: options 新鍵驗證邏輯（domain service 層）

**Files:**
- Modify: `~/Projects/Jedicogy/module/jedi-python-package/jedi-survey/jedi_survey/domain/service/survey_question_domain_service.py`
- Modify: `~/Projects/Jedicogy/module/jedi-python-package/jedi-survey/jedi_survey/common/enum/error_code.py`（新增 error code）
- Test: `~/Projects/Jedicogy/module/jedi-python-package/jedi-survey/tests/unittest/domain/service/test_survey_question_domain_service.py`

驗證規則（design.md §2/§3/§5 定案）：
1. `goto_question_uid`/`goto_page_uids` 只能出現在 `type == "radio"` 的題目 options 上（單選限定；`checkbox`/`dropdown` 等禁止）。
2. `goto_question_uid` 指向的 uid 必須存在（同問卷內的某題），且不能是自己所在的題目（禁止自指）。
3. `goto_page_uids` 陣列內每個 uid 必須是存在的頁面（page）uid。

- [ ] **Step 1: 先寫失敗測試** —— 在 `test_survey_question_domain_service.py` 新增：
```python
def test_add_question_rejects_goto_on_non_radio_type(survey_question_domain_service, mock_survey_question_repo, mock_survey_page_repo):
    """非單選題帶 goto_question_uid 應被拒絕"""
    mock_survey_page_repo.get_by_id.return_value = SurveyPageEntity(id=1, survey_id=1, name="p1")
    mock_survey_question_repo.get_all_by_fields.return_value = []
    entity = SurveyQuestionEntity(
        page_id=1, no="Q1", question="測試", type="checkbox",
        options=[{"name": "A", "goto_question_uid": "some-uid"}],
    )
    with pytest.raises(ConflictError):
        survey_question_domain_service.add_question(entity, locale="zh_Hant_TW")


def test_add_question_rejects_self_reference(survey_question_domain_service, mock_survey_question_repo, mock_survey_page_repo):
    """goto_question_uid 指向自己應被拒絕"""
    mock_survey_page_repo.get_by_id.return_value = SurveyPageEntity(id=1, survey_id=1, name="p1")
    mock_survey_question_repo.get_all_by_fields.return_value = []
    entity = SurveyQuestionEntity(
        page_id=1, no="Q1", question="測試", type="radio", uid="self-uid",
        options=[{"name": "A", "goto_question_uid": "self-uid"}],
    )
    with pytest.raises(ConflictError):
        survey_question_domain_service.add_question(entity, locale="zh_Hant_TW")


def test_add_question_rejects_cross_survey_target(survey_question_domain_service, mock_survey_question_repo, mock_survey_page_repo):
    """goto_question_uid 指向其他問卷的題目應被拒絕（review 補強：避免規則存進去但填答不生效）"""
    mock_survey_page_repo.get_by_id.return_value = SurveyPageEntity(id=1, survey_id=10, name="p1")
    mock_survey_question_repo.get_all_by_fields.return_value = []
    mock_survey_question_repo.get_by_uid.return_value = SurveyQuestionEntity(
        id=99, uid="other-survey-target", page_id=1, no="Q1", question="別份問卷的題", type="text", survey_id=20)
    entity = SurveyQuestionEntity(
        page_id=1, no="Q1", question="測試", type="radio", uid="self-uid", survey_id=10,
        options=[{"name": "A", "goto_question_uid": "other-survey-target"}],
    )
    with pytest.raises(ConflictError):
        survey_question_domain_service.add_question(entity, locale="zh_Hant_TW")
```
- [ ] **Step 2: 執行測試確認失敗**
```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-survey
pytest tests/unittest/domain/service/test_survey_question_domain_service.py -k "goto" -v
```
Expected: FAIL（`ConflictError` 未拋出，因為驗證邏輯還沒寫）

- [ ] **Step 3: 新增 error code**（`common/enum/error_code.py`，接續既有 409 序號）：
```python
SURVEY_FLOW_RULE_INVALID_TYPE = ("流程規則只能設定在單選題", "SURVEY_409010")
SURVEY_FLOW_RULE_SELF_REFERENCE = ("流程規則不可指向自己", "SURVEY_409011")
SURVEY_FLOW_RULE_TARGET_NOT_FOUND = ("流程規則目標題目或題組不存在", "SURVEY_409012")
SURVEY_FLOW_RULE_CROSS_SURVEY = ("流程規則目標必須與來源題屬於同一份問卷", "SURVEY_409013")
```

- [ ] **Step 4: 在 domain service 加驗證方法並在 `add_question`/`update_question` 呼叫**（`survey_question_domain_service.py`）：

**（review 補強）目標必須與來源題同問卷**：`get_by_uid()` 只檢查 uid 是否存在，不會限定同一份問卷——若不額外檢查 `survey_id`，「A 問卷的題指向 B 問卷的題」會通過 BE 驗證但存進去後填答端 `computeVisibility()`（Task 3-1，資料只吃單一問卷）永遠找不到 target，變成「Designer 顯示規則設定成功、填答完全不生效」，正是 design.md 開篇要避免的坑。故新增 `SURVEY_FLOW_RULE_CROSS_SURVEY` 並在下列實作中檢查：

```python
def _validate_flow_rules(self, question_entity: SurveyQuestionEntity, locale: str = None):
    """驗證 options 內的流程規則（goto_question_uid / goto_page_uids）"""
    options = question_entity.options or []
    has_goto = any(
        opt.get("goto_question_uid") or opt.get("goto_page_uids")
        for opt in options if isinstance(opt, dict)
    )
    if not has_goto:
        return
    if question_entity.type != "radio":
        raise ConflictError(ErrorCode.SURVEY_FLOW_RULE_INVALID_TYPE)

    for opt in options:
        if not isinstance(opt, dict):
            continue
        target_q_uid = opt.get("goto_question_uid")
        if target_q_uid:
            if target_q_uid == question_entity.uid:
                raise ConflictError(ErrorCode.SURVEY_FLOW_RULE_SELF_REFERENCE)
            target = self.survey_question_repo.get_by_uid(target_q_uid, locale)
            if target is None:
                raise ConflictError(ErrorCode.SURVEY_FLOW_RULE_TARGET_NOT_FOUND)
            if target.survey_id != question_entity.survey_id:
                raise ConflictError(ErrorCode.SURVEY_FLOW_RULE_CROSS_SURVEY)

        goto_page_uids = opt.get("goto_page_uids") or []
        for page_uid in goto_page_uids:
            if self.survey_page_repo is None:
                continue
            page = self.survey_page_repo.get_by_uid(page_uid, locale)
            if page is None:
                raise ConflictError(ErrorCode.SURVEY_FLOW_RULE_TARGET_NOT_FOUND)
            if page.survey_id != question_entity.survey_id:
                raise ConflictError(ErrorCode.SURVEY_FLOW_RULE_CROSS_SURVEY)
```
在 `add_question()`（`:88`）與 `update_question()`（`:104`）內，於既有驗證之後、實際寫入之前呼叫 `self._validate_flow_rules(question_entity, locale)`。

**注意**：`survey_page_repo` 需支援 `get_by_uid`——查證 `ISurveyPageRepo`（`domain/repository/survey_page.py`）繼承自 `IBaseRepo[T, Q]`，`BaseRepositoryImpl` 已提供通用 `get_by_uid`，`SurveyPageRepoImpl` 未覆寫代表沿用基底實作，可直接呼叫。`question_entity.survey_id`／`SurveyPageEntity.survey_id` 兩者皆為建構子必要參數，`add_question()`（route 層組 entity 時已帶 `survey_id=page.survey_id`）與 `update_question()`（`existing = self.verify_question_exist_by_uid(...)` 載入自 DB，`survey_id` 已存在於既有紀錄）兩條路徑都能保證此欄位有值。

- [ ] **Step 5: 執行測試確認通過**
```bash
pytest tests/unittest/domain/service/test_survey_question_domain_service.py -k "goto" -v
```
Expected: PASS

- [ ] **Step 6: 補正向案例測試**（合法規則不擋、無 goto 鍵零影響）：
```python
def test_add_question_allows_valid_goto_question(survey_question_domain_service, mock_survey_question_repo, mock_survey_page_repo):
    mock_survey_page_repo.get_by_id.return_value = SurveyPageEntity(id=1, survey_id=1, name="p1")
    mock_survey_question_repo.get_all_by_fields.return_value = []
    # 同問卷（survey_id=1）——與新增的 cross-survey 檢查對齊，否則本案例會被 §Step4 補強的 SURVEY_FLOW_RULE_CROSS_SURVEY 誤擋
    mock_survey_question_repo.get_by_uid.return_value = SurveyQuestionEntity(
        id=5, uid="target-uid", page_id=1, no="Q5", question="目標題", type="text", survey_id=1)
    mock_survey_question_repo.add.side_effect = lambda e, locale=None: e
    entity = SurveyQuestionEntity(
        page_id=1, no="Q1", question="測試", type="radio", uid="self-uid", survey_id=1,
        options=[{"name": "A", "goto_question_uid": "target-uid"}],
    )
    result = survey_question_domain_service.add_question(entity, locale="zh_Hant_TW")
    assert result is not None


def test_add_question_no_goto_keys_unaffected(survey_question_domain_service, mock_survey_question_repo, mock_survey_page_repo):
    """無 goto 鍵的既有問卷行為零變化（驗收標準 5）"""
    mock_survey_page_repo.get_by_id.return_value = SurveyPageEntity(id=1, survey_id=1, name="p1")
    mock_survey_question_repo.get_all_by_fields.return_value = []
    mock_survey_question_repo.add.side_effect = lambda e, locale=None: e
    entity = SurveyQuestionEntity(
        page_id=1, no="Q1", question="測試", type="text",
        options=None,
    )
    result = survey_question_domain_service.add_question(entity, locale="zh_Hant_TW")
    assert result is not None
```
- [ ] **Step 7: 全部跑一次確認綠燈**
```bash
pytest tests/unittest/domain/service/test_survey_question_domain_service.py -v
```
Expected: 全部 PASS

- [ ] **Step 8: Commit（jedi-survey 套件 repo）**
```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-survey
git add jedi_survey/domain/service/survey_question_domain_service.py jedi_survey/common/enum/error_code.py tests/unittest/domain/service/test_survey_question_domain_service.py
git commit -m "feat(survey): FR-049 options goto_question_uid/goto_page_uids 流程規則驗證（單選限定/禁自指/目標存在性）"
```

### Task 1-3: 刪除題目/頁面時清理孤兒規則（§0-e 新發現的必要範圍）

**Files:**
- Modify: `~/Projects/Jedicogy/module/jedi-python-package/jedi-survey/jedi_survey/domain/service/survey_question_domain_service.py`
- Modify: `~/Projects/Jedicogy/module/jedi-python-package/jedi-survey/jedi_survey/domain/service/survey_page_domain_service.py`
- Test: 同上兩支對應 test 檔

- [ ] **Step 1: 寫失敗測試**（`test_survey_question_domain_service.py`）：
```python
def test_delete_question_cleans_orphan_goto_question_refs(survey_question_domain_service, mock_survey_question_repo):
    """刪除題目後，其他題目 options 內指向它的 goto_question_uid 應被清除，且清理範圍限定同一份問卷"""
    # target 明確賦值 survey_id=10（review 補強：舊版案例沒設，預設值 0 會讓「同問卷篩選」條件形同虛設，測試通過但沒真正驗證到篩選邏輯）
    target = SurveyQuestionEntity(id=1, uid="target-uid", page_id=1, no="Q1", question="被刪的題", type="text", survey_id=10)
    referencer = SurveyQuestionEntity(
        id=2, uid="ref-uid", page_id=1, no="Q2", question="有規則的題", type="radio", survey_id=10,
        options=[{"name": "A", "goto_question_uid": "target-uid"}],
    )
    mock_survey_question_repo.get_by_uid.return_value = target
    mock_survey_question_repo.get_all_by_fields.return_value = [referencer]
    mock_survey_question_repo.delete_by_uid.return_value = True

    survey_question_domain_service.delete_question("target-uid", locale="zh_Hant_TW")

    # 應以 target.survey_id 查詢範圍（同問卷限定），而非全庫掃描
    call_args = mock_survey_question_repo.get_all_by_fields.call_args
    query_entity = call_args[0][0]
    assert query_entity.survey_id == 10

    # 應呼叫 update 清除 referencer 的 goto_question_uid
    mock_survey_question_repo.update.assert_called_once()
    updated_entity = mock_survey_question_repo.update.call_args[0][0]
    assert "goto_question_uid" not in updated_entity.options[0]
```
- [ ] **Step 2**: 執行確認 FAIL
```bash
pytest tests/unittest/domain/service/test_survey_question_domain_service.py -k "orphan" -v
```

- [ ] **Step 3**: 實作 `delete_question()` 清理邏輯（`survey_question_domain_service.py:118`）：
```python
@transaction
def delete_question(self, uid: str, locale: str = None) -> bool:
    """
    刪除問題（連動清理其他題目指向本題的流程規則）
    """
    question = self.get_question(uid, locale)
    if not question:
        return True

    self._cleanup_goto_question_refs(uid, question.survey_id, locale)
    return self.survey_question_repo.delete_by_uid(uid)


def _cleanup_goto_question_refs(self, deleted_uid: str, survey_id: int, locale: str = None):
    """掃描同問卷所有題目，移除指向 deleted_uid 的 goto_question_uid"""
    all_questions = self.survey_question_repo.get_all_by_fields(
        SurveyQuestionQueryEntity(survey_id=survey_id), locale)
    for q in all_questions:
        if q.uid == deleted_uid or not q.options:
            continue
        changed = False
        for opt in q.options:
            if isinstance(opt, dict) and opt.get("goto_question_uid") == deleted_uid:
                del opt["goto_question_uid"]
                changed = True
        if changed:
            self.survey_question_repo.update(q, locale)
```
- [ ] **Step 4**: 執行確認 PASS

- [ ] **Step 5**: 同樣模式為 `SurveyPageDomainService.delete_page()` 加 `_cleanup_goto_page_refs`（掃描 `goto_page_uids` 陣列移除該 page uid），對應測試 `test_survey_page_domain_service.py` 新增 `test_delete_page_cleans_orphan_goto_page_refs`。

- [ ] **Step 6**: 全部測試跑一次
```bash
pytest tests/unittest/domain/service/ -v
```
Expected: 全綠

- [ ] **Step 7: Commit**
```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-survey
git add jedi_survey/domain/service/survey_question_domain_service.py jedi_survey/domain/service/survey_page_domain_service.py tests/unittest/domain/service/
git commit -m "fix(survey): FR-049 刪除題目/頁面時清理其他題目指向它的流程規則孤兒引用"
```

### Task 1-4: BE 重啟驗證 ✅（2026-07-18 完成，含 §0-a-2 locale failover 實測，結論見上方）

- [x] **Step 1**: 主專案（compliance-manager-be）重啟 BE（user 手動重啟完成）
- [x] **Step 2**: 手測 API：透過 `/api/1.0/login` 取得 JWT 後，PUT `/survey-question/c66394ac-db3e-433e-90fd-706c2fdcab86`（id=8521，radio），`options` 帶 `goto_question_uid` 指向不存在的 uid → 實際回應 `409 SURVEY_409012 SURVEY_FLOW_RULE_TARGET_NOT_FOUND`，符合預期；DB 確認未寫入髒資料（rollback 正常）
- [x] **Step 3**: 同上帶合法目標（`goto_question_uid=34ea7e5a-59d0-4531-b1c5-56fe8cb6e29c`，同問卷 id=8523）→ 200，回讀確認 `options` 內含該鍵；並額外做 §0-a-2 阻塞性查證的翻譯路徑實測（見上方「Task 1-4 DEV 實測結果」段）；測試完畢已清理復原兩表資料

---

## 3. 段②：Designer 接線（flowRules 存讀 + D-3a 子題接線）

同批檔案，合併在一個 Task 群組內處理。

### Task 2-1: survey-mapper.js 補 goto 鍵映射 + parentId 映射修正（D-3a）

**Files:**
- Modify: `~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/utils/survey-mapper.js`

- [ ] **Step 1**: 修改 `mapOptionFromApi()`（`:112-123`），補讀 goto 鍵：
```javascript
export function mapOptionFromApi(o, idx = 0) {
    return {
        id: o.key || o.name || `opt-${idx}`,
        label: o.name || '',
        description: o.content || '',
        hasSupplementInput: !!o.ext_answer,
        supplementDescription: o.ext_answer_desc || '',
        sortOrder: o.sort_order ?? 0,
        gotoQuestionUid: o.goto_question_uid || null,
        gotoPageUids: Array.isArray(o.goto_page_uids) ? o.goto_page_uids : [],
    }
}
```
- [ ] **Step 2**: 修改 `mapOptionToApi()`（`:204-212`），補寫 goto 鍵：
```javascript
export function mapOptionToApi(o) {
    const out = {
        key: o.id || '',
        name: o.label || '',
        content: o.description || '',
        ext_answer: !!o.hasSupplementInput,
        ext_answer_desc: o.supplementDescription || '',
    }
    if (o.gotoQuestionUid) out.goto_question_uid = o.gotoQuestionUid
    if (o.gotoPageUids?.length) out.goto_page_uids = o.gotoPageUids
    return out
}
```
- [ ] **Step 3**: 修正 `mapQuestionFromApi()`（`:92-110`）的 `parentId` 硬編問題（D-3a 根因之一）—— 目前 `parentId: null` 是寫死值，`_pid: q.pid ?? null` 才是真實資料。改為：
```javascript
export function mapQuestionFromApi(q) {
    return {
        id: q.uid,
        _dbId: q.id,
        questionNumber: q.no || '',
        question: q.question || '',
        type: q.type || 'text',
        helpText: q.description || '',
        isRequired: q.required === 1,
        weight: q.weights ?? 1,
        sortOrder: q.sort ?? 0,
        parentId: null,   // FE 樹狀 id 對應需在載入後由呼叫端解析 pid→uid，見 Task 2-3
        _pid: q.pid ?? null,
        options: (q.options || []).map((o, idx) => mapOptionFromApi(o, idx)),
        subQuestions: (q.sub_questions || []).map((sq) => mapQuestionFromApi(sq)),
        flowRules: [],   // 由呼叫端在載入後從 options goto 鍵重組，見 Task 2-3
        supplementDescription: '',
    }
}
```
**保留 `parentId: null` 是刻意的**——BE 回傳的 `sub_questions` 是巢狀陣列（父題物件內含 `sub_questions` 子陣列），不是扁平清單帶 `pid` 指標，所以 FE 不需要「用 uid 查父題」，`question.subQuestions` 本身就是巢狀結構（`SurveyPreview.vue:1511` 已在用 `question.subQuestions`）。`parentId` 真正需要修正的是**寫入路徑**（Task 2-3），不是讀取路徑。

- [ ] **Step 4**: 新增 `mapQuestionToApi()` 補上 `pid` 透傳（若呼叫端有指定父題 db id）：
```javascript
export function mapQuestionToApi(q) {
    const out = {
        no: q.questionNumber || '',
        question: q.question || '',
        type: q.type || 'text',
        description: q.helpText || '',
        required: q.isRequired ? 1 : 0,
        weights: q.weight ?? 1,
        sort: q.sortOrder ?? 0,
        options: (q.options || []).map((o) => mapOptionToApi(o)),
    }
    if (q.id && !String(q.id).startsWith('mock-')) {
        out.uid = q.id
    }
    if (q._parentDbId) {
        out.pid = q._parentDbId
    }
    return out
}
```

### Task 2-2: useSurveyApi.js — createQuestion/updateQuestionApi 補 pid + goto 鍵透傳

**Files:**
- Modify: `~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/composables/useSurveyApi.js`

D-3a 根因二：`createQuestion()`（`:292-313`）payload 已經有 `pid: qData.parentDbId ?? null` 這一行（查證發現這行**其實已經存在**，見下方修正說明），但 `SurveyDesigner.onQuestionSubmit()` 呼叫時從未傳入 `parentDbId` —— 缺口在呼叫端，不在 `useSurveyApi.js`。本 Task 只需處理 goto 鍵透傳；`parentDbId` 缺口留給 Task 2-3。

- [ ] **Step 1**: 修改 `createQuestion()`（`:292-313`）的 options map，補 goto 鍵透傳：
```javascript
async function createQuestion(pageUid, qData) {
    const payload = {
        page_uid: pageUid,
        no: qData.questionNumber || '',
        question: qData.question || '',
        type: qData.type || 'text',
        weights: qData.weight ?? 1,
        sort: qData.sortOrder ?? 0,
        required: qData.isRequired ? 1 : 0,
        pid: qData.parentDbId ?? null,
        options: (qData.options || []).map((o) => ({
            key: o.id || '',
            name: o.label || '',
            content: o.description || '',
            ext_answer: !!o.hasSupplementInput,
            ext_answer_desc: o.supplementDescription || '',
            ...(o.gotoQuestionUid ? { goto_question_uid: o.gotoQuestionUid } : {}),
            ...(o.gotoPageUids?.length ? { goto_page_uids: o.gotoPageUids } : {}),
        })),
        description: qData.helpText || null,
    }
    const data = await baseService.post(API.SURVEY_QUESTION, payload)
    return mapQuestionFromApi(data)
}
```
- [ ] **Step 2**: 同樣修改 `updateQuestionApi()`（`:319-340`）的 `options` map 區塊，補相同的 goto 鍵透傳邏輯。

### Task 2-3: SurveyDesigner.vue + AddQuestionDialog.vue — 接上 parentDbId 與 flowRules（D-3a + flow rules 主接線）

**Files:**
- Modify: `~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/SurveyDesigner.vue`
- Modify: `~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/components/AddQuestionDialog.vue`

**D-3a 根因確認**（§0-d 已查證）：`onQuestionSubmit()`（`SurveyDesigner.vue:392-440`）呼叫 `apiCreateQuestion`/`apiUpdateQuestion` 時完全沒傳 `parentDbId` 或 `flowRules`——`AddQuestionDialog` 的 `form.parentId` 存的是 FE uid（`parentQuestionOptions` 用 `q.id`），需要轉成 `_dbId` 才能餵給 `createQuestion()` 的 `pid`。

- [ ] **Step 1**: 修改 `SurveyDesigner.vue` 的 `onQuestionSubmit()`（`:392-440`），加入 parentDbId 解析 + flowRules 攤平回 options：
```javascript
async function onQuestionSubmit(data) {
    if (!selectedPage.value) return
    showQuestionDialog.value = false

    // D-3a: 解析父題目 uid → db id
    let parentDbId = null
    if (data.parentId) {
        const parentQ = (selectedPage.value.questions || []).find((q) => q.id === data.parentId)
        parentDbId = parentQ?._dbId || null
    }

    // flowRules 攤平回對應 option 的 goto_question_uid（同題組內跳題，目前僅支援單選題目標）
    const optionsWithRules = (data.options || []).map((o) => {
        const rule = (data.flowRules || []).find((r) => r.optionId === o.id)
        return {
            ...o,
            gotoQuestionUid: rule?.targetQuestionId || null,
            gotoPageUids: rule?.targetPageIds || [],
        }
    })

    try {
        if (editQuestionData.value) {
            const updated = await apiUpdateQuestion(editQuestionData.value.id, {
                questionNumber: data.questionNumber,
                question: data.question,
                helpText: data.helpText,
                isRequired: data.isRequired,
                weight: data.weight,
                type: data.type,
                options: optionsWithRules,
            })
            const questions = selectedPage.value.questions
            const idx = questions.findIndex((q) => q.id === editQuestionData.value.id)
            if (idx !== -1) {
                questions.splice(idx, 1, updated)
            }
            toast.add({ severity: 'success', summary: t('lang.survey_v2.success.question_updated'), life: 3000 })
        } else {
            const q = await apiCreateQuestion(selectedPage.value.id, {
                questionNumber: data.questionNumber,
                question: data.question,
                helpText: data.helpText,
                isRequired: data.isRequired,
                weight: data.weight,
                type: data.type,
                options: optionsWithRules,
                parentDbId,
                sortOrder: insertIndex.value >= 0 ? insertIndex.value + 1 : 0,
            })
            const questions = selectedPage.value.questions
            if (insertIndex.value >= 0) {
                questions.splice(insertIndex.value, 0, q)
                const orders = questions.map((item, i) => ({ uid: item.id, sort: i + 1 }))
                reorderQuestions(selectedPage.value.id, orders).catch(() => {})
            } else {
                questions.push(q)
            }
            toast.add({ severity: 'success', summary: t('lang.survey_v2.success.question_created'), life: 3000 })
        }
    } catch {
        toast.add({ severity: 'error', summary: t('lang.common.error'), life: 3000 })
    }
}
```
**注意**：`createQuestion()`（Task 2-2 確認過）payload 已支援 `pid: qData.parentDbId ?? null`，此處呼叫時補上 `parentDbId` 欄位即可對接，無需再改 `useSurveyApi.js` 的 create 部分。

- [ ] **Step 2**: `AddQuestionDialog.vue` 的 `onSubmit()`（`:202-223`）flowRules 組裝需擴充支援 `targetPageIds`（多選題組跳轉）：
```javascript
function onSubmit() {
    if (!validate()) return

    const flowRules = form.value.flowRules
        .filter((r) => r.optionId && (r.targetQuestionId || r.targetPageIds?.length))
        .map((r) => {
            const opt = form.value.options.find((o) => o.id === r.optionId)
            const target = allQuestionsFlat.value.find((q) => q.value === r.targetQuestionId)
            const targetPages = (r.targetPageIds || [])
                .map((pid) => allPagesFlat.value.find((p) => p.value === pid))
                .filter(Boolean)
            return {
                optionId: r.optionId,
                optionLabel: opt?.label || '',
                targetQuestionId: r.targetQuestionId || null,
                targetQuestionLabel: target?.label || '',
                targetPageIds: r.targetPageIds || [],
                targetPageLabels: targetPages.map((p) => p.label),
            }
        })

    emit('submit', {
        ...form.value,
        flowRules,
    })
}
```
- [ ] **Step 3**: 新增 `allPagesFlat` computed（收集題組供「跳題組」下拉用）：
```javascript
const allPagesFlat = computed(() => {
    const result = []
    collectPages(props.allPages, result)
    return result
})

function collectPages(pages, result) {
    for (const p of pages) {
        result.push({ label: p.name, value: p.id })
        if (p.children) collectPages(p.children, result)
    }
}
```
- [ ] **Step 4**: 修改 `addFlowRule()`（`:107-114`）初始值補 `targetPageIds`：
```javascript
function addFlowRule() {
    form.value.flowRules.push({
        optionId: '',
        optionLabel: '',
        targetQuestionId: '',
        targetQuestionLabel: '',
        targetPageIds: [],
        targetPageLabels: [],
    })
}
```
- [ ] **Step 5**: 樣板補「跳題組（多選）」UI（`:399-419` 流程規則區塊內，緊接既有「跳轉至…」單選 Dropdown 之後）：
```html
<span class="aqd-flow-label">{{ t('lang.survey_v2.designer.jump_to_pages') }}</span>
<MultiSelect v-model="rule.targetPageIds"
             :options="allPagesFlat"
             optionLabel="label" optionValue="value"
             display="chip"
             :placeholder="t('lang.survey_v2.designer.question_group')"
             class="aqd-flow-select" />
```
- [ ] **Step 6**: i18n 新增 key（`src/config/locales/i18n/zh-tw/survey-v2.json` 與 `en/survey-v2.json` 的 `designer` 區塊，緊鄰既有 `jump_to`）：
```json
"jump_to_pages": "跳轉至題組（可複選）",
"question_group": "題組"
```
（EN 對應：`"Jump to pages (multi)"` / `"Question group"`）

- [ ] **Step 7**: 手測（不寫自動化 unit test——此段是純 UI 資料流接線，改用 E2E 覆蓋，見 §5 Task 5-2）：
  1. 啟動 FE dev server，進 Designer，開一題單選題，新增 flow rule 選第一選項跳到某題目，儲存
  2. 重整頁面，確認該題卡片顯示流程規則標籤且內容正確（`question.flowRules?.length > 0` 判斷式已存在於 `SurveyDesigner.vue:723`，資料補上後自動生效）
  3. 新增子題（選父題目），儲存後重整，確認子題卡片顯示「子題」標籤且巢狀掛在正確母題下

### Task 2-4: Designer 端規則初始化 — options 載入時把 goto 鍵重組回 flowRules 顯示格式

**Files:**
- Modify: `~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/SurveyDesigner.vue`

`mapQuestionFromApi()`（Task 2-1）刻意把 `flowRules` 留空陣列，因為映射函式拿不到「同一題所有題目清單」的 context 來組 label。需要在 `SurveyDesigner.vue` 載入問卷後（`loadSurvey()` 或等價的 survey 載入完成 hook）跑一次重組：

- [ ] **Step 1**: 新增 `hydrateFlowRules(survey)` 函式，遍歷所有題目，從每個 option 的 `gotoQuestionUid`/`gotoPageUids` 反推組回 `question.flowRules`：
```javascript
function hydrateFlowRules(surveyData) {
    const allQ = flattenAllQuestions(surveyData.pages)
    const allP = []
    ;(function collect(pages) {
        for (const p of pages) {
            allP.push(p)
            if (p.children) collect(p.children)
        }
    })(surveyData.pages)

    for (const q of allQ) {
        const rules = []
        for (const opt of (q.options || [])) {
            if (!opt.gotoQuestionUid && !opt.gotoPageUids?.length) continue
            const target = opt.gotoQuestionUid ? allQ.find((tq) => tq.id === opt.gotoQuestionUid) : null
            const targetPages = (opt.gotoPageUids || [])
                .map((pid) => allP.find((p) => p.id === pid))
                .filter(Boolean)
            rules.push({
                optionId: opt.id,
                optionLabel: opt.label,
                targetQuestionId: opt.gotoQuestionUid || null,
                targetQuestionLabel: target?.question || '',
                targetPageIds: opt.gotoPageUids || [],
                targetPageLabels: targetPages.map((p) => p.name),
            })
        }
        q.flowRules = rules
    }
}
```
- [ ] **Step 2**: 找到 `SurveyDesigner.vue` 載入問卷的 async 函式（`onMounted` 或路由 watch 內呼叫 `getSurveyById`/`survey.value = ...` 之處），在賦值後呼叫 `hydrateFlowRules(survey.value)`。
- [ ] **Step 3**: 手測：Designer 重新整理後，先前設定的流程規則標籤應仍正確顯示（不會因為重整就消失，對應 design.md 驗收標準 4 的延伸）。

### Task 2-5: Commit（主專案 + FE repo 分開 commit）

- [ ] **Step 1**: 主專案（BE）此段無變更，跳過。
- [ ] **Step 2**: FE repo commit：
```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
git add src/views/survey-v2/utils/survey-mapper.js src/views/survey-v2/composables/useSurveyApi.js src/views/survey-v2/SurveyDesigner.vue src/views/survey-v2/components/AddQuestionDialog.vue src/config/locales/i18n/zh-tw/survey-v2.json src/config/locales/i18n/en/survey-v2.json
git commit -m "fix(survey-v2): FR-049 Designer 接線——flowRules 存讀落地 + 子題 parentId 接線（D-3a）"
```

---

## 4. 段③：填答端可見性計算引擎

### Task 3-1: 純函數 `computeVisibility` 實作（composable）

**Files:**
- Create: `~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/composables/useSurveyVisibility.js`

design.md §2 定案語意：
- 初始/未作答 → 規則來源題未作答時不生效 → 預設全可見。
- 同題組內跳：Q1 答 2 跳 Q5 → Q2-4 不可見，Q5 之後照常。
- goto_page：該題組集合以外、同層級「被任一規則提及的題組」不可見；未被提及的題組不受影響。

- [ ] **Step 1**: 撰寫函式：
```javascript
/**
 * 純函數：依全部規則 + 全部當前答案，算出可見的題目/題組集合。
 * 語意：初始/未作答 = 全可見；規則來源題答了才生效略過。
 */
export function computeVisibility(allQuestions, allPages, answers) {
    // 1. 收集所有規則：{ sourceQuestionId, optionId, gotoQuestionUid, gotoPageUids }
    const rules = []
    for (const q of allQuestions) {
        if (q.type !== 'radio') continue
        for (const opt of (q.options || [])) {
            if (opt.gotoQuestionUid || opt.gotoPageUids?.length) {
                rules.push({
                    sourceQuestionId: q.id,
                    sourcePageId: q._pageId,
                    optionId: opt.id,
                    gotoQuestionUid: opt.gotoQuestionUid || null,
                    gotoPageUids: opt.gotoPageUids || [],
                })
            }
        }
    }

    // 無規則的問卷：全可見（驗收標準 5 的零變化保證）
    if (rules.length === 0) {
        return {
            visibleQuestionIds: new Set(allQuestions.map((q) => q.id)),
            visiblePageIds: new Set(allPages.map((p) => p.id)),
        }
    }

    const hiddenQuestionIds = new Set()
    const hiddenPageIds = new Set()

    // 同一頁面內題目的線性順序（供「同題組內跳題」起訖判斷）
    const questionsByPage = new Map()
    for (const q of allQuestions) {
        const list = questionsByPage.get(q._pageId) || []
        list.push(q)
        questionsByPage.set(q._pageId, list)
    }
    for (const list of questionsByPage.values()) {
        list.sort((a, b) => a.sortOrder - b.sortOrder)
    }

    // 所有「被任一規則提及」的題組 id（goto_page 語意：只影響被提及者，未提及者不受影響）
    const mentionedPageIds = new Set()
    for (const r of rules) {
        for (const pid of r.gotoPageUids) mentionedPageIds.add(pid)
    }

    for (const rule of rules) {
        const sourceQ = allQuestions.find((q) => q.id === rule.sourceQuestionId)
        if (!sourceQ) continue
        const ans = answers[rule.sourceQuestionId]
        const answerValue = ans && typeof ans === 'object' && 'answer' in ans ? ans.answer : ans
        // 未作答：規則不生效
        if (answerValue === undefined || answerValue === null || answerValue === '') continue
        // 這條規則不是「當前選中的選項」設的規則：不生效
        if (answerValue !== rule.optionId) continue

        // 同題組內跳題：Q(source) 之後、Q(target) 之前的同頁題目略過；target 之後照常
        if (rule.gotoQuestionUid) {
            const pageList = questionsByPage.get(sourceQ._pageId) || []
            const sourceIdx = pageList.findIndex((q) => q.id === rule.sourceQuestionId)
            const targetIdx = pageList.findIndex((q) => q.id === rule.gotoQuestionUid)
            if (sourceIdx !== -1 && targetIdx !== -1 && targetIdx > sourceIdx) {
                for (let i = sourceIdx + 1; i < targetIdx; i++) {
                    hiddenQuestionIds.add(pageList[i].id)
                }
            }
        }

        // goto_page：本規則指到的題組留下可見；同層級其他「被提及但這次沒被選中」的題組隱藏
        if (rule.gotoPageUids.length > 0) {
            for (const pid of mentionedPageIds) {
                if (!rule.gotoPageUids.includes(pid)) {
                    hiddenPageIds.add(pid)
                }
            }
            // 這條規則自己指到的題組：確保不被其他規則誤隱藏（後面統一從 hidden 移除）
            for (const pid of rule.gotoPageUids) {
                hiddenPageIds.delete(pid)
            }
        }
    }

    const visibleQuestionIds = new Set(
        allQuestions.filter((q) => !hiddenQuestionIds.has(q.id) && !hiddenPageIds.has(q._pageId)).map((q) => q.id)
    )
    const visiblePageIds = new Set(
        allPages.filter((p) => !hiddenPageIds.has(p.id)).map((p) => p.id)
    )

    return { visibleQuestionIds, visiblePageIds }
}
```

**✅ 已裁決（2026-07-18 指揮官：採 OR 寬鬆語意，Step 1 草案維持原樣）**。原阻塞說明保留如下供脈絡：多來源交叉情境未經 design.md 拍板，本 Step 1 程式碼內的處理方式原為暫定草案（review 指出：不應該把未拍板的演算法直接寫死進主線並排入 commit 清單，只在文字註解「記錄供討論」，這等於先斬後奏）：

多條規則同時指到同一批 `mentionedPageIds` 時（例如兩題各自設了指向同一題組的規則），本函式草案（上方 Step 1 程式碼）用 `hiddenPageIds.delete()` 採「只要有一條規則的當前選中結果要顯示，就顯示」的寬鬆判斷——這在單一來源題（design.md 情境是唯一一題「系統部署環境」決定 B/C 是否顯示）下沒有爭議，但 design.md 全文未定義這種多來源交叉情境的語意，**這是本 plan 自行推導的暫定行為，不代表已拍板的需求**。

- [x] **Step 1-b（已解除，2026-07-18 指揮官裁決）**：採寬鬆判斷（任一規則要求顯示即顯示）→ Step 1 程式碼維持原樣，補 Step 1-c 測試後方可排入 Task 3-5 commit。
- [ ] **Step 1-c**：撰寫案例驗證多來源交叉情境的實際行為（`useSurveyVisibility.js` 若走 Vitest；本 FE repo 目前只有 Cypress，若無合適單元測試框架則改為 Task 6.2 E2E scenario 覆蓋，需與 Step 1-b 的結論一併記錄在 §6 測試總覽）：兩題（Q_a、Q_b）皆為單選題，皆設規則「答某選項 → 跳到題組 X」，模擬「Q_a 選了跳 X 的選項、Q_b 選了不跳 X 的選項」，驗證題組 X 的可見性是否符合 Step 1-b 指揮官確認的預期行為。

- [ ] **Step 2**: `allQuestions`/`allPages` 需要帶 `_pageId` 欄位（目前 `collectAllQuestions()` 遞迴時沒有標記所屬頁面 id）——此為本函式的前置資料需求，在 Task 3-2 整合時處理。

### Task 3-2: SurveyPreview.vue 整合可見性引擎（三消費點同源）

**Files:**
- Modify: `~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/SurveyPreview.vue`

- [ ] **Step 1**: import 新 composable：
```javascript
import { computeVisibility } from './composables/useSurveyVisibility'
```
- [ ] **Step 2**: 修改 `collectAllQuestions()`（`:538-553`）在遞迴時標記 `_pageId`：
```javascript
function collectAllQuestions(pages) {
    const result = []
    function walkPages(list) {
        for (const p of list) {
            for (const q of (p.questions || [])) {
                result.push({ ...q, _pageId: p.id })
                if (q.subQuestions?.length) {
                    result.push(...q.subQuestions.map((sq) => ({ ...sq, _pageId: p.id })))
                }
            }
            if (p.children?.length) walkPages(p.children)
        }
    }
    walkPages(pages)
    return result
}
```
- [ ] **Step 3**: 新增 `visibility` computed（緊接 `allQuestions` computed 之後，`:585-588`）：
```javascript
const visibility = computed(() => {
    if (!survey.value) return { visibleQuestionIds: new Set(), visiblePageIds: new Set() }
    return computeVisibility(allQuestions.value, allPagesFlat.value, answers.value)
})
```
- [ ] **Step 4**: 改 `totalRequired`（`:590`）只算可見題：
```javascript
const totalRequired = computed(() =>
    allQuestions.value.filter((q) => q.isRequired && visibility.value.visibleQuestionIds.has(q.id)).length
)
```
- [ ] **Step 5**: 改 `answeredCount`（`:592-594`）同理只算可見題（分母分子同源，避免「已答但不可見的題目」污染百分比）：
```javascript
const answeredCount = computed(() => {
    return allQuestions.value
        .filter((q) => visibility.value.visibleQuestionIds.has(q.id))
        .filter((q) => isAnswered(q.id)).length
})
```
- [ ] **Step 6**: `progress`（`:596-599`）分母改為可見題總數：
```javascript
const progress = computed(() => {
    const visibleCount = allQuestions.value.filter((q) => visibility.value.visibleQuestionIds.has(q.id)).length
    if (visibleCount === 0) return 0
    return Math.round((answeredCount.value / visibleCount) * 100)
})
```
- [ ] **Step 7**: 改 `currentQuestions`（`:563-567`）過濾不可見題（渲染消費點）：
```javascript
const currentQuestions = computed(() => {
    if (!currentPage.value) return []
    return (currentPage.value.questions || [])
        .filter((q) => visibility.value.visibleQuestionIds.has(q.id))
        .sort((a, b) => a.sortOrder - b.sortOrder)
})
```
- [ ] **Step 8**: 改 `allPagesFlat`（`:556-559`）過濾不可見題組（若 goto_page 規則隱藏整個題組，該題組不應出現在分頁導覽）：
```javascript
const allPagesFlat = computed(() => {
    if (!survey.value) return []
    const flat = flattenPagesWithDepth(survey.value.pages)
    return flat.filter((p) => visibility.value.visiblePageIds.has(p.id))
})
```
**注意循環相依風險**：`visibility` computed 依賴 `allPagesFlat`，而 Step 8 又讓 `allPagesFlat` 依賴 `visibility`——這是**真實的循環相依**，必須拆解。修正方案：`computeVisibility` 的 `allPages` 參數改吃**未過濾的原始扁平頁面清單**（新增一個不受可見性影響的 `allPagesRaw` computed，只做 `flattenPagesWithDepth`），`allPagesFlat`（給 UI 導覽用）則是 `allPagesRaw` 過濾 `visibility.visiblePageIds` 後的結果：
```javascript
const allPagesRaw = computed(() => {
    if (!survey.value) return []
    return flattenPagesWithDepth(survey.value.pages)
})

const visibility = computed(() => {
    if (!survey.value) return { visibleQuestionIds: new Set(), visiblePageIds: new Set() }
    return computeVisibility(allQuestions.value, allPagesRaw.value, answers.value)
})

const allPagesFlat = computed(() => {
    return allPagesRaw.value.filter((p) => visibility.value.visiblePageIds.has(p.id))
})
```
（Step 3 的 `visibility` 定義需改為吃 `allPagesRaw.value`，Step 8 的 `allPagesFlat` 改為上述過濾版本。）

- [ ] **Step 9**: 改 `onSave()` 必填檢核（`:751-766`，同源要求的核心）：
```javascript
if (status === 2 || status === 9) {
    const missing = allQuestions.value.filter(
        (q) => q.isRequired && visibility.value.visibleQuestionIds.has(q.id) && !isAnswered(q.id)
    )
    if (missing.length > 0) {
        // ...既有 toast 邏輯不變
    }
}
```
- [ ] **Step 10**: `progressItems`（`:601-623`）同樣改用可見集合過濾 `questions`/`subQuestions`：
```javascript
const progressItems = computed(() =>
    allPagesFlat.value.map((p, idx) => {
        const questions = (p.questions || []).filter((q) => visibility.value.visibleQuestionIds.has(q.id))
        let totalQ = 0
        let answeredQ = 0
        for (const q of questions) {
            totalQ++
            if (isAnswered(q.id)) answeredQ++
            for (const sq of (q.subQuestions || [])) {
                if (!visibility.value.visibleQuestionIds.has(sq.id)) continue
                totalQ++
                if (isAnswered(sq.id)) answeredQ++
            }
        }
        return {
            ...p, index: idx, answered: answeredQ, total: totalQ,
            complete: totalQ > 0 && answeredQ === totalQ,
        }
    })
)
```

### Task 3-3: 已隱藏題答案保留（不刪除，不新增持久化 flag）

design.md §2 已定案「已填而後被隱藏的答案：保留資料不刪除」——查證 `answers.value` 是純前端 reactive state，被隱藏題目只是不出現在 `currentQuestions`/`allQuestions` 過濾結果內，`answers.value[qid]` 本身未被清空，**現有資料結構天然滿足此要求，不需要額外程式碼**。此 Task 僅需驗證：

- [ ] **Step 1**: 手測：填一題 A-1 選「地端」（此時 B 題組可見、C 題組隱藏），到 B 題組填一題答案，回頭改 A-1 為「雲端」（B 隱藏、C 顯示），再改回「地端」——確認 B 題組原本填的答案還在（未被清空）。

### Task 3-4: 提交/暫存 payload 不受可見性影響（維持現況）

`onTempSave()`（`:720-742`）與 `checkpoint` API 呼叫走 `buildPatchesFromDirty()`（dirty tracking，非全量 `allQuestions` 掃描）——查證這條路徑跟 `totalRequired`/`onSave` 的必填檢核是分開的兩套機制，**不需要改動**，因為只有「使用者實際填過的欄位」才會進 patches，隱藏題目本來就不會被使用者填寫。此 Task 為確認性質，無程式碼異動。

- [ ] **Step 1**: 手測：確認暫存/提交不會把隱藏題目的初始空答案意外送到 BE（觀察 Network tab 的 `/checkpoint` request body）。

### Task 3-5: Commit

**前置條件**：Task 3-1 的 Step 1-b（多來源交叉情境）必須已取得指揮官回覆並完成對應調整/驗證（Step 1-c），才能執行本 Task 的 commit。若 Step 1-b 尚未解除阻塞，本段改為暫存工作區變更（不 commit），待確認後再回頭補 commit。

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
git add src/views/survey-v2/composables/useSurveyVisibility.js src/views/survey-v2/SurveyPreview.vue
git commit -m "feat(survey-v2): FR-049 填答端可見性計算引擎——渲染/進度/必填三消費點同源"
```

---

## 5. 段④：稽核檢視端（不適用 vs 漏答）

### Task 4-1: BE — checkpoint 落地 flow_rules 快照（§0-b 方案，✅ 指揮官已拍板：方案 X）

**✅ 已裁決（2026-07-18 指揮官）：採方案 X——快照掛每筆 `TaskSurveyAnswerHistory`。** 新增 JSONB 欄位走 `sql-migration` skill 鐵則寫 migration（寫好先不套，套用時機回報指揮官）。後續 steps 由執行者依方案 X 現場展開。原兩案權衡表保留如下供脈絡（當時刻意不預展開任一方案細節，避免引導）：

design.md 原文只說「此前提 implementation plan 時驗證，若發布後仍可改結構則改為提交時落 snapshot flag」，未明確二擇一。以下用中性語言並列兩案的權衡，不帶建議傾向：

| | 方案 X：快照掛在每筆 `TaskSurveyAnswerHistory` | 方案 Y：快照掛在 `TaskSurvey` 本體 |
|---|---|---|
| 寫入時機 | 每次 checkpoint 都存一份 | 只在首次進入問卷時鎖定一份，後續 checkpoint 不更新 |
| 語意準確度 | 每筆歷史紀錄都對應「該次提交當下」的規則版本，與規則被事後修改的情境完全對齊 | 若規則在填答期間被 Designer 改過，快照仍是最早那份，與「提交當下」的規則版本可能不一致 |
| 資料量 | 隨 checkpoint 次數線性增長（history 表本身已隨每次提交寫入，此為既有寫入路徑上追加欄位，非新增寫入次數） | 只多一份，資料量最小 |
| 稽核檢視回溯 history 版本 | 每筆 history 各自有對應規則快照，回看任一版本歷史都能精確重算 | 所有版本共用同一份快照，無法回溯「規則在期間被改過」的情境差異 |
| 新增欄位落點 | `TaskSurveyAnswerHistory`（需查證是否已有 JSONB 欄位可用，或需新增） | `TaskSurvey`（需查證是否已有 JSONB 欄位可用，或需新增） |
| 若需新增欄位 | 屬於 schema migration，需走 `sql-migration` skill | 屬於 schema migration，需走 `sql-migration` skill |
| 影響檔案（僅供指揮官評估落點時參考，非最終定案） | `app/task_survey/service/question_answer_service.py` 的 `checkpoint_task_survey_answer`（每次寫 history 的段落） | 同一支檔案的 `checkpoint_task_survey_answer`（改為只在初次寫入時落地一次），或另一個「首次進入問卷」的既有入口點（待查證） |

- [x] **Step 0（已解除，2026-07-18 指揮官拍板：方案 X——快照掛每筆 `TaskSurveyAnswerHistory`）**。本 Task 解除 blocked，進入實作。

*(以下 steps 由執行者根據方案 X 現場展開：`checkpoint_task_survey_answer` 每次寫 history 時，從已載入的 `q_uid_to_obj` options 抽取 goto 鍵落 `flow_rules_snapshot` JSONB；新欄位 migration 照 sql-migration 鐵則，寫好先不套。)*

### Task 4-2: 稽核檢視端渲染「不適用」標記

**Files:**
- Modify: `~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/SurveyPreview.vue`（`mode === 'view'`/`'history'` 分支）

- [ ] **Step 1**: 在 `view`/`history` 模式下，用 Task 4-1 落地的快照規則跑 `computeVisibility()`（而非即時問卷結構），得出 `hiddenQuestionIds`。
- [ ] **Step 2**: 題目渲染區塊（`:1230+`）補充：`isRequired && !hasAnswer(id)` 目前只顯示「漏答」提示（`:1253-1257`），需要區分：
   - 若題目在 `hiddenQuestionIds` 內（依 X-N 作答略過）→ 顯示「不適用（依 X-N 作答略過）」（灰色，非紅色警示）
   - 若題目不在隱藏集合但未答 → 維持既有紅色「漏答」提示
- [ ] **Step 3**: i18n 新增 `not_applicable` / `not_applicable_reason` 等 key。
- [ ] **Step 4**: 手測：以 site-regression 遺留的部署環境三情境資料，在 `view` 模式檢查略過題正確顯示「不適用」而非「漏答」。

**（本 Task 因依賴 Task 4-1 的落點決策，實作前同樣需等 Task 4-1 的 blocking 解除後才能精確定案渲染邏輯的資料來源，目前先給出結構性步驟。）**

---

## 6. 測試總覽

### 6.1 jedi-survey pytest（段①完成後執行）

已於 Task 1-2/1-3 內嵌 TDD 步驟，彙整清單：
- `test_add_question_rejects_goto_on_non_radio_type`
- `test_add_question_rejects_self_reference`
- `test_add_question_allows_valid_goto_question`
- `test_add_question_no_goto_keys_unaffected`（既有問卷零變化）
- `test_delete_question_cleans_orphan_goto_question_refs`
- `test_delete_page_cleans_orphan_goto_page_refs`

```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-survey
pytest tests/unittest/domain/service/test_survey_question_domain_service.py tests/unittest/domain/service/test_survey_page_domain_service.py -v
```
Expected: 全綠，且既有測試（未涉及 goto 鍵）不受影響。

### 6.2 E2E 場景清單（test repo，另派 session/agent 執行，本 plan 只列範圍）

**目標 feature 檔**：`compliance-manager-test/site-regression/features/modules/survey/survey-v2-designer.feature` + `survey-v2-fill.feature`

**移除既有 `@known-bug`**（D-3a 修復後）：
- `survey-v2-designer.feature` 第 152-168 行「子題：新增子題掛在母題下」—— 移除 `@known-bug` tag
- `survey-v2-designer.feature` 第 170-187 行「流程規則：選項跳題設定成功並顯示於題目卡」—— 移除 `@known-bug` tag

**新增 scenario（`survey-v2-designer.feature`）**：
- 跳題組（多選）：新增流程規則選一選項跳到「B、C 兩題組」，儲存後題目卡顯示兩題組標籤
- 刪除被規則指向的題目：刪除 Q5（被 Q1 規則指向）後，Q1 的流程規則標籤應消失（孤兒清理生效）
- 刪除被規則指向的題組：同理驗證 goto_page_uids 清理

**新增 scenario（`survey-v2-fill.feature`）**：
- 部署環境三情境完整跑通（design.md 驗收標準 1）：單選【地端/雲端/混合】，分別驗證 B/C/B+C 題目出現
- 同題組內「答 2 跳第 5 題」，驗證 Q2-4 不算入進度分母（驗收標準 2）
- 回頭改答案重算：改變 A-1 答案後，先前題組已填答案應保留（驗收標準 3）
- 必填檢核只作用可見題：某必填題被規則略過時，不應擋住提交按鈕

**回歸範圍**（review 時用 `grep -c "Scenario:"` 重新精確計數，design.md §7 提及的「既有 47 條」未涵蓋 `survey-v2-manage.feature`，本 plan 沿用時發現與精確計數有落差，予以修正）：`survey-v2-designer.feature`（實際 19 個 scenario）+ `survey-v2-fill.feature`（實際 15 個 scenario）= 34 條為本次直接相關的核心回歸範圍（與 design.md §7「既有 47 條」的差異待與指揮官確認原始基準是否含其他 feature 或計數時間點不同，不影響本次改動的驗證有效性）；`survey-v2-manage.feature`（實際 18 個 scenario，未受本次改動影響）一併跑過確認零回歸。三檔合計 52 條既有 scenario + 上述新增 scenario 全部需綠燈。

**執行方式**：依 CLAUDE.md「測試相關工作一律在 compliance-manager-test 專案進行」，E2E 撰寫與執行需另開 session 或指派 test repo 的對應 agent，本 plan 只界定範圍與座標，不在本次 BE plan session 內執行。

### 6.3 手測 checklist（段②③完成後，開發者自行過一輪）

- [ ] Designer 新增子題（選父題目）→ 存檔 → 重整 → 子題仍在正確母題下
- [ ] Designer 新增流程規則（單選題跳到另一題）→ 存檔 → 重整 → 規則標籤仍顯示
- [ ] Designer 新增流程規則（跳題組多選）→ 存檔 → 重整 → 規則標籤顯示正確題組名稱
- [ ] Designer 刪除被規則指向的題目 → 原規則的來源題規則標籤消失
- [ ] 填答端（`/survey/fill`）：地端/雲端/混合三情境跳題行為正確
- [ ] 填答端：同題組內跳題略過的題目不算入進度分母
- [ ] 填答端：回頭改答案，先前題組已填答案保留不消失
- [ ] 填答端：被規則略過的必填題不擋提交（vs 未被略過的必填題仍擋提交）
- [ ] 預覽模式（`/survey/preview/:id`）與任務填答模式行為一致（同一份 SurveyPreview.vue 驗證）
- [ ] 既有無規則問卷（既有 495+ 份 v2 問卷代表案例，任選 1-2 份跑過）：填答/進度/必填行為與改動前完全一致

---

## 7. 收尾（等 user 明確下令，本 plan 不預先執行）

依 CLAUDE.md「收尾必須等 user 下命令才做」：完成上述所有段落並手測過關後，**停下回報狀態 + 手測 checklist 結果**，不主動進行：
- jedi-survey 套件發版（path dependency 還原 pin）
- `docs/specs/` 對應頁面 spec 更新（走 `writing-feature-specs` skill）
- Notion / memory / SUMMARY 等收尾類文件

以上一律等使用者明確下令後才啟動對應 skill。
