# FR-049.1 問卷顯示條件（目標側重構）Implementation Plan

> **母案**：FR-049（來源側 goto，四段已 commit 未部署——jedi-survey `4e9cd41` / FE `5d73876` / BE checkpoint 快照）。本案是資料模型翻轉：跳題規則從「來源選項宣告目的地（goto_*）」改為「目標題目/題組宣告顯示條件（display_condition）」。
> **設計真相**：`docs/features/FR-049.1-2607-survey-display-conditions/design.md`（§2 六決策全拍板，不重開）。
> **狀態**：plan 待 review（階段一）。核准後才進實作（階段二）。
> **For agentic workers:** 逐 Task 做；jedi-survey 驗證邏輯 TDD 先測後碼；migration 寫好【先不套】，套用時機回報指揮官。

**Goal:** 把 FR-049 的來源側 goto 模型翻成目標側 display_condition：BE（jedi-survey）加 `display_condition` 欄位（**刻意排除 translatable_fields，繞開 jedi-common 翻譯 bug**）+ 驗證/清理改對象 → Designer 目標側「顯示條件」UI + 子題入口重構（決策 2/3）+ mapper 改鍵 → 填答端 `computeVisibility` 換輸入形狀 → 稽核檢視端快照換內容。FR-049 骨架（可見性引擎雙函數、快照機制全鏈、三消費點同源架構、子題 pid 接線）**保留換輸入**（design §5 delta 不 revert）。

**Architecture:** `display_condition` 是**題目/題組級**屬性（非選項級），落 `SurveyQuestion` / `SurveyPage` 本體各一個新 JSONB 欄位（需 migration）。填答端沿用單一純函數，語意翻轉為「掃全部目標的 display_condition，判斷每個目標當下該不該顯示」——比 goto 起訖區間演算法更簡單、無 OR 合併爭議。

**Tech Stack:** Python（jedi-survey path dependency dev loop，目前已是 path 狀態，symlink 已驗）/ Vue 3 + PrimeVue / pytest / Cucumber+Playwright（E2E 另派）。

---

## 0. 前提查證（開工前 live 驗證結果，2026-07-18）

逐項對 jedi-survey / jedi-common / FE / BE 原始碼 + DEV DB 實查。

### a. `display_condition` 落欄位 + translatable_fields 家族檢查 ✅ **決定：新增專用欄位、刻意排除 translatable_fields**

- **無現成雜項欄可掛**：`SurveyQuestion`（`jedi_survey/infra/models/survey_question.py`）JSONB 欄位只有 `options` / `tags` / `references`，皆有既定語意，不可借掛。`SurveyPage`（`.../survey_page.py`）連 JSONB 欄位都沒有（只有 `name` / `description` / `sort` / `no`）。→ **兩表各需新增一個 `display_condition` JSONB 欄位**（migration）。
- **translatable_fields 家族檢查（design §3.2 硬性要求）**：
  - `SurveyQuestion` 的可翻譯欄位 = `['question', 'description', 'options']`（`survey_question_repo_impl.py:36` `_setup_translation` + `survey_question_mapper.py:12`）。
  - `SurveyPage` 的可翻譯欄位 = `['name', 'description']`（`survey_page_repo_impl.py` + `survey_page_mapper.py:12`）。
- **決定性查證 — 只要 `display_condition` 不進 translatable_fields，就完全繞開 jedi-common bug**：直讀 jedi-common `_get_failover_query()`（`base_repository_impl.py:642-690`）——failover 讀取對**非可翻譯欄位走 `load_only`（直接讀主表原值）**，只有可翻譯欄位才套 `coalesce(trans.col, main.col)`。而寫入端 bug（`base_repository_impl.py:628-633` 的 `field not in self._translatable_fields` 型別誤判）造成「所有欄位都被當非可翻譯、無條件寫回主表」——對一個**本來就非可翻譯**的欄位而言，「無條件寫回主表」正是我們要的正確行為。
  - **結論**：`display_condition` **不加入** `translatable_fields`。讀（load_only 讀主表）、寫（主表）雙路徑都乾淨落主表，**與 `docs/issues/pending/2026-07-18-jedi-common-translation-exclude-never-works.md` 的 bug 零耦合**——不像 FR-049 的 goto（掛在可翻譯的 `options` 內、正確性依賴 bug 副作用）。這是目標側模型比來源側模型在工程上更乾淨的附帶收益。
  - **反面確認**：若誤把 `display_condition` 塞進 `options`（沿用 FR-049 落點）就會重新踩進翻譯家族耦合——故 design §3.2「落 SurveyQuestion 本體」的裁決在此得到工程佐證，照辦。

### b. FR-049 已 commit 的 goto 驗證/清理 hook/mapper 現況座標 ✅（退役/改造精確定位）

**jedi-survey（commit `4e9cd41`）：**
| 資產 | 座標 | FR-049.1 處置 |
|------|------|--------------|
| `_validate_flow_rules` 方法 | `survey_question_domain_service.py:123-157` | **重做**→ `_validate_display_condition`（驗對象換成 display_condition） |
| 驗證呼叫點 | 同檔 `:100`（add_question）、`:118`（update_question） | 換呼叫新方法 |
| `_cleanup_goto_question_refs` | 同檔 `:173-186` | **重做**→ 清理「引用被刪題的 display_condition」（含 page 表） |
| delete_question 清理呼叫 | 同檔 `:168` | 換呼叫新清理 |
| `_cleanup_goto_page_refs` | `survey_page_domain_service.py:291-311` | **退役**（display_condition source 只會是 question，page 不當 source，刪 page 無「引用它的規則」要清） |
| delete_page 清理呼叫 | 同檔 `:285` | 刪除該行 |
| error codes | `common/enum/error_code.py:27-30`（SURVEY_409010~013 FLOW_RULE 系列） | **重做**→ DISPLAY_CONDITION 系列（沿用 409010~013 序號，改語意） |
| goto 驗證/清理 14 測試 | `tests/unittest/domain/service/test_survey_question_domain_service.py:533-668` + `test_survey_page_domain_service.py:412` | **退役**，改寫 display_condition 對應測試 |

**FE（commit `5d73876`）：**
| 資產 | 座標 | 處置 |
|------|------|------|
| options goto 鍵映射 | `survey-mapper.js:122-123`（from）`:217-218`（to） | **退役**（改 question/page 級 displayCondition 映射） |
| flow rules 編輯 UI | `AddQuestionDialog.vue:418-463`（step3 流程規則區） | **退役**，換「顯示條件」區 |
| 父題目下拉 | 同檔 `:401-407` + `parentQuestionOptions:166` + `form.parentId` | **退役**（決策 3，改母題卡「+新增延伸題」入口） |
| flowRules 組裝 | 同檔 `onSubmit:223-246` `addFlowRule:110` `removeFlowRule:121` `canSetFlowRules:80` | **退役** |
| flow rules 卡片預覽 | `SurveyDesigner.vue:811-821` | **重做**→ 條件顯示 badge |
| hydrateFlowRules | `SurveyDesigner.vue`（`grep hydrateFlowRules` 定位，含 `:91` gotoPageUids 反查） | **退役**（display_condition 直接掛題目/題組，無需 hydrate 重組 label） |
| goto 透傳 | `useSurveyApi.js` createQuestion/updateQuestionApi options map（goto 鍵） | **退役**，改 display_condition 透傳 |
| `extractRulesFromQuestions` / `extractRulesFromSnapshot` | `useSurveyVisibility.js:14-53` | **重做**（換掃 display_condition） |
| `computeVisibilityFromRules` 核心 | 同檔 `:59-138`（goto 起訖區間 + OR 合併） | **重做**（目標側逐目標判斷，演算法簡化） |

**BE checkpoint：** `_extract_flow_rules_snapshot`（`app/task_survey/service/question_answer_service.py:32-62`）**重做**→ 抽 display_condition 快照。

### c. `computeVisibility` / `computeVisibilityFromSnapshot` 現行輸入形狀 ✅

- `computeVisibility(allQuestions, allPages, answers)`（`useSurveyVisibility.js:141`）→ `extractRulesFromQuestions` 掃 `question.options[].gotoQuestionUid/gotoPageUids`。
- `computeVisibilityFromSnapshot(flowRulesSnapshot, allQuestions, allPages, answers)`（`:152`）→ `extractRulesFromSnapshot` 掃 `[{source_question_uid, options:[{option_key, goto_question_uid, goto_page_uids}]}]`。
- 兩者共用 `computeVisibilityFromRules`（`:59`）產出 `{visibleQuestionIds, visiblePageIds, hiddenReasons}`。
- **消費點（`SurveyPreview.vue`）全部只吃輸出的三個集合**，未直接碰規則形狀：`visibility`（`:570`）/`auditVisibility`（`:594`）/`totalRequired`（`:654`）/`answeredCount`（`:659`）/`progress`/`currentQuestions`（`:633`）/`allPagesFlat`（`:624`）/`progressItems`（`:674`）/`onSave` 必填（`:827`）/`hiddenReasons` 文案（`:611`）。
- **改動面**：只需改 `useSurveyVisibility.js` 兩個 extract 函式 + core 演算法；**SurveyPreview.vue 消費點幾乎零改**（輸出契約不變），唯一新增：`allPages` 需帶 `displayCondition` 欄位（目前 `allPagesRaw` 只 flatten，未帶 display_condition，Task 3-2 補）。

### d. Designer 列表子題現況（design §4.1 稱「1-1-1 仍平級」）✅ 根因定位

- **BE 讀取路徑巢狀正確**：ORM `SurveyPage.questions` relationship `primaryjoin` 帶 `SurveyQuestion.pid == None`（只回根題），根題的 `sub_questions` relationship 自動載入子題；serializer `SurveyPageResponse.questions`（`api/survey/serializers/survey_page.py:42`）+ `SurveyQuestionResponse.sub_questions`（`survey_question.py:58`）巢狀 dump。DEV 實查有真實子題資料（survey_id=360 有 206 筆 pid≠null）。
- **FE 讀取路徑巢狀正確**：`survey-mapper.js:106` `subQuestions: (q.sub_questions||[]).map(...)`；`SurveyDesigner.vue:828-843` 有子題嵌套 render（縮排 + 左邊框）。
- **根因**：不是讀取漏視圖，是**建立後本地狀態平推**——`onQuestionSubmit`/`onCopyQuestion` 建立成功後直接 `selectedPage.value.questions.push(q)`（如 `:530`），把新題（含子題）平推進根層 `questions` 陣列，**沒依 `pid` 掛回母題的 `subQuestions`**，畫面立即出現一張平級卡片；要重整（重走巢狀讀取路徑）才會正確。決策 3 把子題入口改成「母題卡 +新增延伸題」後，建立成功的本地插入需改為 push 進母題的 `subQuestions`（而非根 `questions`），此 bug 一併修掉。
- **驗證方式**：實作 Task 2-x 後，FE dev server 起 → Designer 母題卡「+新增延伸題」建子題 → **不重整**即應嵌套顯示（非平級）；再重整確認一致。（需 FE runtime，服務 user 起。）

### e. checkpoint / revert 快照抽取現況 ✅

- **抽取函式**：`_extract_flow_rules_snapshot(questions)`（`question_answer_service.py:32-62`）——掃 `question.options[].goto_*`，產 `[{source_question_uid, options:[{option_key, goto_question_uid, goto_page_uids}]}]`。**只吃 questions，不吃 pages**（goto 掛在 option 上，pages 無 goto）。→ **重做**：display_condition 掛在 question **與 page** 本體，新抽取需同時掃兩者（checkpoint 目前只 `get_questions()`，**需補載 pages**，見 Task 4-1）。
- **落地**：checkpoint 每筆 history 落 `flow_rules_snapshot`（`:339`）。
- **欄位**：`survey.question_answer_histories.flow_rules_snapshot`（jsonb, nullable）——**DEV 已存在**（本 session psql 實查確認），migration `scripts/sql/2026-07-18-fr049-flow-rules-snapshot.sql` 已套 DEV。**照 design §3.3 沿用不回滾、不改欄名**，只換 JSONB 內容形狀。
- **revert 繼承**：`question_answer_history_service.py:110` 建 revert 複本時 `flow_rules_snapshot=answer_history.flow_rules_snapshot` 繼承目標快照——**行為不變**（機制與內容無關）。
- **snapshot 消費**：`SurveyPreview.vue:586` 讀 `target.flow_rules_snapshot` → `computeVisibilityFromSnapshot`。內容形狀改變後，`extractRulesFromSnapshot` 同步改（Task 4-2）。

---

## 1. 範圍、依賴順序、退役清單

### 1.1 依賴順序（四段鏈，沿母案形態；每段驗證後才進下一段）

```
段① jedi-survey BE（欄位+migration+驗證+清理）
        ↓  Designer 依賴 BE 能存 display_condition
段② Designer（顯示條件 UI + 子題入口重構 + mapper 改鍵）
        ↓  填答端依賴 Designer 能正確寫入 display_condition
段③ 填答端（computeVisibility 換輸入）
        ↓  稽核端依賴 checkpoint 落新形狀快照
段④ 稽核檢視端（snapshot 內容換 + computeVisibilityFromSnapshot 換輸入）
```

### 1.2 退役清單（退役 = 刪碼，不留死碼）

- **jedi-survey**：`_validate_flow_rules` 全 goto 邏輯、`_cleanup_goto_page_refs` 整個方法、goto 14 測試、FLOW_RULE error code 文案（序號沿用改語意）。
- **FE**：AddQuestionDialog 流程規則區 + 父題目下拉、SurveyDesigner flow rules 預覽 + `hydrateFlowRules`、mapper goto 鍵映射、useSurveyApi goto 透傳、useSurveyVisibility 舊 extract + goto 起訖演算法。
- **BE**：`_extract_flow_rules_snapshot` goto 抽取邏輯。

### 1.3 Not-in-scope（design §7 backlog + 本次界定）

- 跳題精靈（來源題快捷設定自動翻譯成中間題條件）——backlog。
- 多來源/複合條件（AND/OR 跨題）、非單選來源題型、跨問卷——BE 驗證直接擋。
- goto_* 舊鍵：**不讀不寫**（新引擎只認 display_condition）；DEV 上 FR-049 測試期可能殘留的 goto 鍵成為 inert 死鍵，不遷移不清理（design §3.1）。
- Excel 匯入（`import_survey`）不支援 display_condition，匯入問卷無條件、可後續 Designer 補設。
- E2E：舊 goto 場景止損（另派 session 重寫，本 plan 只列範圍 §7）。

---

## 2. 段①：jedi-survey BE（display_condition 欄位 + migration + 驗證 + 清理）

**環境**：jedi-survey path dependency（已是 path 狀態、symlink 已驗，不需重切）。

### Task 1-1: display_condition 欄位落地（model + entity + mapper，**排除 translatable_fields**）

**Files（jedi-survey）:**
- Modify: `jedi_survey/infra/models/survey_question.py`（加 `display_condition` JSONB 欄）
- Modify: `jedi_survey/infra/models/survey_page.py`（加 `display_condition` JSONB 欄）
- Modify: `jedi_survey/domain/entities/survey_question_entity.py`（`__init__` 加 `display_condition=None` + `self.display_condition`）
- Modify: `jedi_survey/domain/entities/survey_page_entity.py`（同）
- Modify: `jedi_survey/infra/mapper/survey_question_mapper.py`（`to_entity` 加 `display_condition=survey_question.display_condition`；**translatable_fields 不動**）
- Modify: `jedi_survey/infra/mapper/survey_page_mapper.py`（同）

- [ ] **Step 1**: 兩個 model 各加：
```python
display_condition: Mapped[Optional[Any]] = mapped_column(
    JSONB, nullable=True,
    comment="顯示條件（FR-049.1）：{source_question_uid, answers:[option_key]}；null=恆顯示"
)
```
- [ ] **Step 2**: 兩個 entity `__init__` 加參數 `display_condition=None` 與 `self.display_condition = display_condition`。
- [ ] **Step 3**: 兩個 mapper `to_entity` 補 `display_condition=...`。**確認 translatable_fields 未加入 display_condition**（前提查證 a 的核心，繞開翻譯 bug）。
- [ ] **Step 4**: 反查 repo `_setup_translation` 的 `translatable_fields` 清單，確認**未含** display_condition（question=`['question','description','options']`、page=`['name','description']` 維持原樣）。

**驗證方式**：`python3 -c "import jedi_survey; from jedi_survey.infra.models.survey_question import SurveyQuestion; print('display_condition' in SurveyQuestion.__table__.columns)"` → True。

### Task 1-2: Migration（2 新欄位，**寫好先不套**）

**Files:**
- Create: `scripts/sql/2026-07-18-fr0491-display-condition.sql`（主專案）

- [ ] **Step 1**: 照 `sql-migration` skill 鐵則寫（檔頭 `-- Date:`、每語句日期註解、additive nullable 欄不需 re-GRANT、`INSERT public.schema_migrations`、`ON CONFLICT DO NOTHING`）：
```sql
-- Date: 2026-07-18
-- FR-049.1 問卷顯示條件（目標側）—— 題目/題組各加 display_condition JSONB
-- 語意：{source_question_uid, answers:[option_key]}；null=恆顯示。刻意不列入 translatable_fields，
-- 讀寫走主表、與 jedi-common 翻譯 bug 零耦合（見 implementation-plan §0-a）。
-- Run with: psql --single-transaction -v ON_ERROR_STOP=1 -f <file>  (account: cmmgr)

ALTER TABLE survey.survey_questions
  ADD COLUMN IF NOT EXISTS display_condition JSONB NULL; -- 2026-07-18
COMMENT ON COLUMN survey.survey_questions.display_condition IS
  'FR-049.1 顯示條件：{source_question_uid, answers:[option_key]}；null=恆顯示'; -- 2026-07-18

ALTER TABLE survey.survey_pages
  ADD COLUMN IF NOT EXISTS display_condition JSONB NULL; -- 2026-07-18
COMMENT ON COLUMN survey.survey_pages.display_condition IS
  'FR-049.1 顯示條件：{source_question_uid, answers:[option_key]}；null=恆顯示'; -- 2026-07-18

INSERT INTO public.schema_migrations(filename, note) VALUES
  ('2026-07-18-fr0491-display-condition.sql',
   'FR-049.1 / add display_condition to survey.survey_questions + survey.survey_pages（目標側顯示條件）')
ON CONFLICT (filename) DO NOTHING; -- 2026-07-18
```
- [ ] **Step 2**: **不套用**。寫好後回報指揮官套用時機（DEV 先套才能手測段①之後）。⚠️ 硬規則：任何 DB 寫入先報再動。

### Task 1-3: display_condition 驗證邏輯（TDD 先測後碼）

**Files:**
- Modify: `jedi_survey/domain/service/survey_question_domain_service.py`（`_validate_display_condition` 取代 `_validate_flow_rules`）
- Modify: `jedi_survey/domain/service/survey_page_domain_service.py`（page 也要驗 display_condition）
- Modify: `jedi_survey/common/enum/error_code.py`（FLOW_RULE 系列改 DISPLAY_CONDITION）
- Test: `tests/unittest/domain/service/test_survey_question_domain_service.py` + `test_survey_page_domain_service.py`

**驗證規則（design §3.1 v1 範圍）：**
1. `display_condition` 存在 → 必含 `source_question_uid`（str）+ `answers`（非空 list），否則 malformed 擋。
2. `source_question_uid` 必須存在、同問卷、**type=='radio'**（單選來源限定）。
3. question 級：source 不可指向自己（禁自指）；page 級無自指問題（source 恆為 question）。

- [ ] **Step 1（先寫失敗測試，退役舊 goto 測試）**：刪 `:533-668` goto 測試，新增：
```python
def test_question_rejects_display_condition_non_radio_source(...):
    """source 指向非單選題 → SURVEY_DISPLAY_CONDITION_SOURCE_NOT_RADIO"""
def test_question_rejects_display_condition_self_reference(...):
    """source 指向自己 → SURVEY_DISPLAY_CONDITION_SELF_REFERENCE"""
def test_question_rejects_display_condition_source_not_found(...):
    """source uid 不存在 → SURVEY_DISPLAY_CONDITION_SOURCE_NOT_FOUND"""
def test_question_rejects_display_condition_cross_survey(...):
    """source 在別份問卷 → SURVEY_DISPLAY_CONDITION_CROSS_SURVEY"""
def test_question_rejects_display_condition_malformed(...):
    """缺 source_question_uid 或 answers 空 → SURVEY_DISPLAY_CONDITION_MALFORMED"""
def test_question_allows_valid_display_condition(...):
    """合法（radio source、同問卷、answers 非空）→ 通過"""
def test_question_no_display_condition_unaffected(...):
    """display_condition=None → 零影響（驗收情境對照 design §6-1 未作答全可見前置）"""
def test_page_rejects_display_condition_non_radio_source(...):  # page 版
def test_page_allows_valid_display_condition(...):              # page 版
```
- [ ] **Step 2**: 跑測試確認 FAIL（新方法未實作）。
- [ ] **Step 3**: error_code：`FLOW_RULE` 四碼改語意（沿 409010~013 序號）+ 補一碼：
```python
SURVEY_DISPLAY_CONDITION_SOURCE_NOT_RADIO = ("顯示條件的來源題必須是單選題", "SURVEY_409010")
SURVEY_DISPLAY_CONDITION_SELF_REFERENCE = ("顯示條件的來源題不可是自己", "SURVEY_409011")
SURVEY_DISPLAY_CONDITION_SOURCE_NOT_FOUND = ("顯示條件的來源題不存在", "SURVEY_409012")
SURVEY_DISPLAY_CONDITION_CROSS_SURVEY = ("顯示條件的來源題必須與本題屬於同一份問卷", "SURVEY_409013")
SURVEY_DISPLAY_CONDITION_MALFORMED = ("顯示條件格式錯誤（缺來源題或答案清單為空）", "SURVEY_400001")  # 序號接既有 400 系列
```
- [ ] **Step 4**: 實作共用驗證（question domain service，page domain service 呼叫同語意但略過自指）：
```python
def _validate_display_condition(self, entity, is_question: bool, locale: str = None):
    dc = entity.display_condition
    if not dc:
        return
    source_uid = dc.get("source_question_uid")
    answers = dc.get("answers")
    if not source_uid or not isinstance(answers, list) or len(answers) == 0:
        raise BadRequestError(ErrorCode.SURVEY_DISPLAY_CONDITION_MALFORMED)
    if is_question and source_uid == entity.uid:
        raise ConflictError(ErrorCode.SURVEY_DISPLAY_CONDITION_SELF_REFERENCE)
    source = self.survey_question_repo.get_by_uid(source_uid, locale)
    if source is None:
        raise ConflictError(ErrorCode.SURVEY_DISPLAY_CONDITION_SOURCE_NOT_FOUND)
    if source.survey_id != entity.survey_id:
        raise ConflictError(ErrorCode.SURVEY_DISPLAY_CONDITION_CROSS_SURVEY)
    if source.type != "radio":
        raise ConflictError(ErrorCode.SURVEY_DISPLAY_CONDITION_SOURCE_NOT_RADIO)
```
在 `add_question`/`update_question`（`:100`/`:118` 改呼叫）與 `add_page`/`update_page` 呼叫（page domain service 需能取 `survey_question_repo`——查證 `_cleanup_goto_page_refs` 已在用 `self.survey_question_repo`，代表已注入，可直接用）。
- [ ] **Step 5**: 跑測試確認 PASS。

### Task 1-4: 刪來源題連動清理 display_condition（重做 §0-e 清理）

**Files:**
- Modify: `jedi_survey/domain/service/survey_question_domain_service.py`（`_cleanup_goto_question_refs` → `_cleanup_display_condition_refs`，掃 question **與 page** 兩表）
- Modify: `jedi_survey/domain/service/survey_page_domain_service.py`（刪 `_cleanup_goto_page_refs:291-311` + delete_page 呼叫 `:285`——page 非 source 無需清理）
- Test: 對應兩 test 檔

- [ ] **Step 1（先寫失敗測試）**：
```python
def test_delete_question_clears_display_condition_on_questions_and_pages(...):
    """刪來源題 → 引用它的 question.display_condition 與 page.display_condition 皆被清空（限同問卷）"""
def test_delete_page_no_display_condition_cleanup_needed(...):
    """刪 page 不觸發 display_condition 清理（page 非 source）——回歸確認 _cleanup_goto_page_refs 已退役"""
```
- [ ] **Step 2**: 跑確認 FAIL。
- [ ] **Step 3**: 實作 `delete_question` 清理（`:168` 換呼叫）：
```python
def _cleanup_display_condition_refs(self, deleted_uid, survey_id, locale=None):
    # 掃同問卷所有題目
    for q in self.survey_question_repo.get_all_by_fields(
            SurveyQuestionQueryEntity(survey_id=survey_id), locale):
        dc = q.display_condition
        if dc and dc.get("source_question_uid") == deleted_uid:
            q.display_condition = None
            self.survey_question_repo.update(q, locale)
    # 掃同問卷所有題組（需注入 survey_page_repo；查證已有 self.survey_page_repo）
    for p in self.survey_page_repo.get_all_by_fields(
            SurveyPageQueryEntity(survey_id=survey_id), locale):
        dc = p.display_condition
        if dc and dc.get("source_question_uid") == deleted_uid:
            p.display_condition = None
            self.survey_page_repo.update(p, locale)
```
- [ ] **Step 4**: 刪 `survey_page_domain_service.py` 的 `_cleanup_goto_page_refs`（`:291-311`）與 `delete_page` 的呼叫行（`:285`）。
- [ ] **Step 5**: 跑確認 PASS。
- [ ] **Step 6（來源題改題型的清理，design §4.1「來源題改為其他題型 → 同樣清空」）**：`update_question` 內若偵測 type 由 radio 改為非 radio，對「引用本題的 display_condition」做同款清理。查證 `update_question` 目前 `verify_question_exist_by_uid` 已載入 existing，可比對 `existing.type == 'radio' and new.type != 'radio'` 觸發 `_cleanup_display_condition_refs(question_entity.uid, survey_id, locale)`。補測試 `test_change_source_type_from_radio_clears_referencing_conditions`。

### Task 1-5: BE serializer 透傳 display_condition

**Files（主專案）:**
- Modify: `api/survey/serializers/survey_question.py`（Create/Update Request + Response 加 `display_condition = fields.Raw(allow_none=True, load_default=None)`）
- Modify: `api/survey/serializers/survey_page.py`（同）

- [ ] **Step 1**: `SurveyQuestionCreateRequest` / `SurveyQuestionUpdateRequest` / `SurveyQuestionResponse` 各加 `display_condition`。
- [ ] **Step 2**: `SurveyPageResponse` + page 的 Create/Update Request 同加。
- [ ] **Step 3**: 確認全問卷整棵樹 PUT（`api/survey/serializers/survey.py` 的 `pages = fields.List(fields.Dict())` + `questions` 開放 dict）passthrough——查證 FR-049 已驗開放 dict 原樣通過，display_condition 走整樹 PUT 不需改 survey.py。但 route 組 entity 時需確認 `SurveyQuestionEntity(**question)` / `SurveyPageEntity(**page)` 能吃 `display_condition` key（Task 1-1 已加 entity 參數，✅）。

### Task 1-6: 段① 全測試 + commit（jedi-survey repo）

- [ ] **Step 1**: `cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-survey && pytest tests/unittest/domain/service/ -v` → 全綠（新 display_condition 測試通過、既有非 goto 測試不受影響）。
- [ ] **Step 2**: 顯式 git add（列出檔案，不用 -am），commit（等指揮官驗收後才 commit——依紀律，本 plan 不預先 commit）。

**⚠️ 段① 驗證需 DEV 套 migration + 重啟 BE**：Task 1-2 migration 套用時機回報指揮官；BE 改套件後提醒 user 重啟。

---

## 3. 段②：Designer（目標側顯示條件 UI + 子題入口重構 + mapper 改鍵）

### Task 2-1: survey-mapper.js — 退役 goto 鍵、改 question/page 級 displayCondition 映射

**Files（FE）:** `src/views/survey-v2/utils/survey-mapper.js`

- [ ] **Step 1**: `mapOptionFromApi`/`mapOptionToApi` **移除** `gotoQuestionUid`/`gotoPageUids`（`:122-123`/`:217-218`）。
- [ ] **Step 2**: `mapQuestionFromApi`（`:92`）加 `displayCondition: q.display_condition || null`；`mapQuestionToApi`（`:159`）加 `if (q.displayCondition) out.display_condition = q.displayCondition`。
- [ ] **Step 3**: `mapPageFromApi`（`:77`）加 `displayCondition: p.display_condition || null`；page 的 toApi（`mapPageToApi` 若有，否則整樹 PUT 走 `mapSurveyToApi`）同補。

### Task 2-2: AddQuestionDialog.vue — 退役流程規則/父題目、加「顯示條件」區

**Files:** `src/views/survey-v2/components/AddQuestionDialog.vue`

- [ ] **Step 1**: **退役** step3 流程規則區（`:418-463`）、父題目下拉（`:401-407`）、`parentQuestionOptions`（`:166`）、`form.parentId`、`addFlowRule`/`removeFlowRule`/`canSetFlowRules`、`onSubmit` 的 flowRules 組裝（`:223-246`）。
- [ ] **Step 2**: 加「顯示條件」區（題目 dialog，design §4.1）：
  - `form.displayCondition = { sourceQuestionUid: null, answers: [] }`（或 null）。
  - UI：「當 [單選題▼] 的答案是 [選項多選▼] 時顯示」+ 清除鈕。
  - 來源題下拉 `radioQuestionOptions`：`allQuestionsFlat` filter `type==='radio' && id!==本題 && 非本題子題`。
  - 答案多選 `sourceAnswerOptions`：選中來源題後，列該題 options 的 `{label:opt.label, value:opt.id}`（id=key||name，與填答值同源）。
- [ ] **Step 3**: `onSubmit` emit `{...form, displayCondition}`（sourceQuestionUid+answers 皆空時送 null）。
- [ ] **Step 4**: 「進階設定」步驟內容重整（決策：父題目 + 流程規則移除後，step3 只剩補充描述 + 顯示條件；步驟數/導覽視內容合併，實作時定）。
- [ ] **Step 5**: i18n 新增 `display_condition` / `show_when` / `condition_source` / `condition_answers` / `clear_condition` key（zh-tw + en）；移除 `flow_rules`/`jump_to`/`jump_to_pages` 等 goto 文案。

### Task 2-3: 題組（SurveyPage）顯示條件 UI

**Files:** `src/views/survey-v2/components/AddPageDialog.vue`（或題組設定入口，`grep` 定位）

- [ ] **Step 1**: 題組設定 dialog 同加「顯示條件」區（與題目同形狀，design §4.1「題目/題組設定各加」）。來源題下拉列同問卷所有單選題。
- [ ] **Step 2**: page create/update payload（`useSurveyApi.js` createPage/updatePage）透傳 `display_condition`。

### Task 2-4: SurveyDesigner.vue — 退役 hydrate/flow 預覽、加條件 badge + 子題入口重構

**Files:** `src/views/survey-v2/SurveyDesigner.vue`

- [ ] **Step 1**: **退役** `hydrateFlowRules`（grep 定位）、flow rules 卡片預覽（`:811-821`）。
- [ ] **Step 2**: 加「條件顯示 badge」：題目/題組卡片有 `displayCondition` 時顯示「條件顯示：依 <來源題編號>」（design §4.1 全局視圖）。來源題編號查 `allQuestionsFlat` by `sourceQuestionUid`。
- [ ] **Step 3（決策 3 子題入口）**：母題卡 footer 加「+新增延伸題」按鈕 → 開 AddQuestionDialog 並預設 `parentDbId=母題._dbId`（取代父題目下拉）。
- [ ] **Step 4（修查證 d 平推 bug）**：`onQuestionSubmit` 建立成功後，若是子題（有 parentDbId），本地插入改為 push 進母題的 `subQuestions` 陣列（而非根 `questions`），確保**不重整**即嵌套顯示。
- [ ] **Step 5（決策 2 子題排序）**：子題卡不提供獨立上移/下移（跟母題走）——確認子題卡 action bar 無獨立 reorder 控制。
- [ ] **Step 6**: `onQuestionSubmit`/`useSurveyApi` create/update payload 退役 goto 透傳、改帶 `displayCondition`。

### Task 2-5: 段② 手測 + commit（FE repo）

- [ ] **Step 1**: FE dev server 起（user 起服務）。手測：
  1. 題目設顯示條件（選單選來源+答案）→ 存 → 重整 → badge 正確顯示。
  2. 題組設顯示條件 → 存 → 重整 → badge 正確。
  3. 母題卡「+新增延伸題」→ 建子題 → **不重整**即嵌套顯示（查證 d 修復驗證）。
  4. 刪來源題 → 引用它的條件 badge 消失（段① 清理連動）。
- [ ] **Step 2**: 顯式 git add + commit（等指揮官驗收後）。

---

## 4. 段③：填答端可見性引擎（換 display_condition 輸入）

### Task 3-1: useSurveyVisibility.js — 重做 extract + core 演算法（目標側）

**Files:** `src/views/survey-v2/composables/useSurveyVisibility.js`（重做，保留匯出函式名 `computeVisibility`/`computeVisibilityFromSnapshot` 讓 SurveyPreview 消費點零改）

**目標側語意（design §3.1，比 goto 簡單）：**
- 每個 target（question 或 page）看自己的 `displayCondition`：
  - 無 displayCondition → 恆可見。
  - 有 → source 未作答 **→ 可見**（維持「答了才略過」）；source 已作答且答案 ∈ answers → 可見；已作答且答案 ∉ answers → **隱藏**。
- page 隱藏 → 其下所有 question 隱藏。
- `hiddenReasons`：target → source_question_uid（供「依 X-N 略過」文案）。

- [ ] **Step 1（TDD——本 FE repo 若有 Vitest 走單元測試，否則 §7 E2E 覆蓋 + 手測）**：先寫 core 演算法測試（未作答全可見 / 命中顯示 / 未命中隱藏 / page 隱藏連帶 question / 無條件零變化）。
- [ ] **Step 2**: 重做 core：
```javascript
function computeVisibilityFromConditions(questionConds, pageConds, allQuestions, allPages, answers) {
    const hiddenQ = new Set(), hiddenP = new Set(), hiddenReasons = new Map()
    const isHidden = (dc) => {
        if (!dc?.sourceQuestionUid || !dc.answers?.length) return false
        const ans = answers[dc.sourceQuestionUid]
        const v = ans && typeof ans === 'object' && 'answer' in ans ? ans.answer : ans
        if (v === undefined || v === null || v === '') return false  // 未作答=顯示
        return !dc.answers.includes(v)  // 已作答未命中=隱藏
    }
    for (const p of allPages) {
        if (isHidden(p.displayCondition)) { hiddenP.add(p.id); hiddenReasons.set(p.id, p.displayCondition.sourceQuestionUid) }
    }
    for (const q of allQuestions) {
        if (hiddenP.has(q._pageId)) { hiddenQ.add(q.id); if(!hiddenReasons.has(q.id)) hiddenReasons.set(q.id, allPages.find(p=>p.id===q._pageId)?.displayCondition?.sourceQuestionUid) ; continue }
        if (isHidden(q.displayCondition)) { hiddenQ.add(q.id); hiddenReasons.set(q.id, q.displayCondition.sourceQuestionUid) }
    }
    return {
        visibleQuestionIds: new Set(allQuestions.filter(q=>!hiddenQ.has(q.id)).map(q=>q.id)),
        visiblePageIds: new Set(allPages.filter(p=>!hiddenP.has(p.id)).map(p=>p.id)),
        hiddenReasons,
    }
}
export function computeVisibility(allQuestions, allPages, answers) {
    return computeVisibilityFromConditions(null, null, allQuestions, allPages, answers)
    // question/page 的 displayCondition 直接掛在物件上，無需另抽
}
```
- [ ] **Step 3**: `computeVisibilityFromSnapshot` 換新快照形狀（見 Task 4-2 快照格式 `[{target_type, target_uid, source_question_uid, answers}]`）：把快照的 target 條件覆寫回對應 question/page 的 displayCondition，再走同一 core（讓 view/history 讀「提交當下」條件而非即時結構）。

### Task 3-2: SurveyPreview.vue — 補 allPages 帶 displayCondition，消費點沿用

**Files:** `src/views/survey-v2/SurveyPreview.vue`

- [ ] **Step 1**: `allPagesRaw`（`:558`）flatten 時帶上 `displayCondition`（目前只 flatten 結構，需保留 `p.displayCondition`）；`collectAllQuestions`（`:538`）已帶 `_pageId`，補帶 `q.displayCondition`（mapper 已映射，應已在物件上，確認即可）。
- [ ] **Step 2**: `visibility` computed（`:570`）呼叫 `computeVisibility(allQuestions.value, allPagesRaw.value, answers.value)`——**簽章不變**（allPages 現在帶 displayCondition）。三消費點（totalRequired/currentQuestions/onSave 必填 + progress + progressItems）**零改**（輸出契約不變，查證 c）。
- [ ] **Step 3**: `hiddenReasons` 文案（`:611`）沿用（source_question_uid → 編號）。
- [ ] **Step 4**: 手測：地端/雲端/混合三情境（design §6-1）；同題組跳題（§6-3，現在是對中間題各設 `Q1≠2` 條件）；改答重算、答案保留（§6-3）；必填同源（§6-4）。

### Task 3-3: 段③ commit（FE repo，等指揮官驗收後）

---

## 5. 段④：稽核檢視端（快照內容換 + computeVisibilityFromSnapshot 換輸入）

### Task 4-1: BE checkpoint — 重做快照抽取（掃 question + page 的 display_condition）

**Files:** `app/task_survey/service/question_answer_service.py`

- [ ] **Step 1**: `_extract_flow_rules_snapshot` 重做為 `_extract_display_conditions_snapshot(questions, pages)`：
```python
def _extract_display_conditions_snapshot(questions, pages) -> list[dict]:
    snap = []
    for q in questions:
        dc = getattr(q, "display_condition", None)
        if dc and dc.get("source_question_uid"):
            snap.append({"target_type": "question", "target_uid": q.uid,
                         "source_question_uid": dc["source_question_uid"], "answers": dc.get("answers") or []})
    for p in pages:
        dc = getattr(p, "display_condition", None)
        if dc and dc.get("source_question_uid"):
            snap.append({"target_type": "page", "target_uid": p.uid,
                         "source_question_uid": dc["source_question_uid"], "answers": dc.get("answers") or []})
    return snap
```
- [ ] **Step 2**: checkpoint 三處寫 history（`:110`/`:243`/`:339` 區）**需補載 pages**——目前只 `get_questions(survey_id=...)`。查證是否有 `survey_page_domain_service.get_by_survey_id(survey_id)` 可用（jedi-survey 有此 method），載入 pages 傳進抽取函式。落地 `flow_rules_snapshot=_extract_display_conditions_snapshot(questions, pages)`（欄名沿用不改，design §3.3）。
- [ ] **Step 3**: revert 繼承（`question_answer_history_service.py:110`）**行為不變**（機制與內容無關，確認即可）。

### Task 4-2: 稽核檢視端渲染（view/history 不適用標記）

**Files:** `src/views/survey-v2/SurveyPreview.vue`

- [ ] **Step 1**: `extractRulesFromSnapshot`（在 useSurveyVisibility）換讀新形狀 `[{target_type, target_uid, source_question_uid, answers}]`，覆寫回 target 的 displayCondition 後走 core（Task 3-1 Step 3）。
- [ ] **Step 2**: view/history 模式的「不適用 vs 漏答」渲染（`:1351`/`:1645` 區）**沿用**——`auditVisibility` 產出的 hiddenQuestionIds + hiddenReasons 邏輯不變，只是規則來源換成 display_condition 快照。
- [ ] **Step 3**: 手測（design §6-5）：提交後 Designer 改條件 → 稽核檢視按提交當下快照判定不適用、不漂移；revert 版本繼承目標快照。

### Task 4-3: 段④ commit（BE + FE 分開，等指揮官驗收後）

---

## 6. 驗收情境映射（design §6 七情境 → Task）

| # | design §6 情境 | 對應 Task | 驗證方式 |
|---|---------------|-----------|---------|
| 1 | 部署環境三分流（B 設 A-1∈{地端,混合}、C 設 A-1∈{雲端,混合}） | 2-3（page 條件 UI）+ 3-1/3-2（填答引擎） | 手測 + E2E：答地端出 A,B,D~L；雲端出 A,C,D~L；混合全出；未作答全出 |
| 2 | 新增選項無漏網（A-1 加「其他」不動條件 → 答其他 B,C 不顯示、D~L 照常） | 3-1 演算法（字面語意） | 手測：加選項後答之，B/C 隱藏、其餘正常——無靜默漏答整章 |
| 3 | 同組跳題（Q2-Q4 各設 Q1≠2 → Q1 答 2 消失、進度分母同步、改答恢復保留） | 2-2（question 條件）+ 3-2（進度同源）+ 3-3（答案保留） | 手測：Q1 答 2 → Q2-4 隱藏、分母減；改答恢復、B 已填答案還在 |
| 4 | 必填同源（被隱藏必填不擋提交、可見必填未填仍擋） | 3-2 Step 2（onSave 必填吃 visibleQuestionIds） | 手測：隱藏必填不擋 submit；可見必填擋 |
| 5 | 稽核回溯（提交後改條件不漂移、revert 繼承） | 4-1（快照落地）+ 4-2（快照重算） | 手測：改條件後檢視按快照判定；revert 繼承 |
| 6 | Designer 防呆（刪來源題/改題型連動清空、badge 正確） | 1-4（清理）+ 2-4（badge） | pytest（清理）+ 手測（badge 顯示/消失） |
| 7 | 子題（母題卡新增延伸題、嵌套顯示、無獨立排序、跟母題出現） | 2-4 Step 3-5（入口+平推修復+排序） | 手測：不重整即嵌套；填答端跟母題一起出現 |

---

## 7. 測試總覽

### 7.1 jedi-survey pytest（段①）
- display_condition 驗證：non_radio_source / self_reference / source_not_found / cross_survey / malformed / valid / no_condition（question + page）
- 清理：delete_question 清 question+page 條件 / delete_page 不觸發清理（回歸）/ 改題型清理
```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-survey && pytest tests/unittest/domain/service/ -v
```

### 7.2 E2E（test repo，另派 session）
- **退役**：舊 goto designer/fill scenario（模型翻轉，止損重寫）。
- **新增（designer）**：題目/題組設顯示條件 + badge、刪來源題連動清空、母題卡新增延伸題嵌套。
- **新增（fill）**：部署環境三分流、新增選項無漏網、同組跳題進度分母、改答重算保留、必填同源。
- **回歸**：survey-v2 無條件既有問卷零變化。

### 7.3 手測 checklist（段②③④，開發者過一輪）
- [ ] 題目/題組設顯示條件 → 存 → 重整 → badge 正確
- [ ] 部署環境三分流填答行為正確（含混合）
- [ ] 新增選項不動條件 → 答新選項 → 條件字面隱藏正確
- [ ] 同組跳題略過題不算進度分母
- [ ] 改答重算、先前已填答案保留
- [ ] 隱藏必填不擋提交 / 可見必填擋提交
- [ ] 母題卡新增延伸題 → 不重整即嵌套
- [ ] 刪來源題 → 引用它的條件 badge 消失
- [ ] 稽核檢視按提交快照判不適用、不漂移；revert 繼承
- [ ] 無條件既有問卷（任選 1-2 份）行為零變化

---

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

依 CLAUDE.md「收尾等命令」：完成所有段落 + 手測過關後，**停下回報 status + checklist 結果**，不主動做：
- jedi-survey 發版（path dependency 還原 pin）
- migration 套用 STG/POC（DEV 套用時機亦回報指揮官）
- `docs/specs/` 頁面 spec 更新（`writing-feature-specs` skill）
- Notion / memory / SUMMARY

---

## 附錄：關鍵風險與已解事項

- **jedi-common 翻譯 bug 耦合 → 本案零耦合**（前提查證 a）：display_condition 為專用非可翻譯欄位，read（load_only 讀主表）+ write（主表）雙路徑乾淨，不像 FR-049 goto 依賴 bug 副作用。此為目標側模型的工程附帶收益。
- **goto 舊鍵 inert**：DEV 若殘留 FR-049 測試期 goto 鍵，新引擎不讀，成死鍵，不遷移（design §3.1）。
- **snapshot 欄位沿用**：`question_answer_histories.flow_rules_snapshot` 不回滾不改名，只換內容形狀（design §3.3）。
- **checkpoint 需補載 pages**（Task 4-1）：display_condition 掛 page 本體，抽快照需額外載入 pages（原 goto 只在 question option 上）。
- **查證 d 平推 bug**：FR-049.1 子題入口重構順帶修「建立後平推根層」的既有 FE bug。
