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

  • 無現成雜項欄可掛SurveyQuestionjedi_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-633field 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.vuegrep 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_snapshotapp/task_survey/service/question_answer_service.py:32-62重做→ 抽 display_condition 快照。

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

  • computeVisibility(allQuestions, allPages, answers)useSurveyVisibility.js:141)→ extractRulesFromQuestionsquestion.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 primaryjoinSurveyQuestion.pid == None(只回根題),根題的 sub_questions relationship 自動載入子題;serializer SurveyPageResponse.questionsapi/survey/serializers/survey_page.py:42)+ SurveyQuestionResponse.sub_questionssurvey_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:586target.flow_rules_snapshotcomputeVisibilityFromSnapshot。內容形狀改變後,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.pyto_entitydisplay_condition=survey_question.display_conditiontranslatable_fields 不動
  • Modify: jedi_survey/infra/mapper/survey_page_mapper.py(同)
display_condition: Mapped[Optional[Any]] = mapped_column(
    JSONB, nullable=True,
    comment="顯示條件(FR-049.1):{source_question_uid, answers:[option_key]};null=恆顯示"
)

驗證方式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(主專案)
-- 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

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)。
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 版
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 系列
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,代表已注入,可直接用)。

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 檔
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 已退役"""
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)

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(同)

Task 1-6: 段① 全測試 + commit(jedi-survey repo)

⚠️ 段① 驗證需 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

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

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

    • form.displayCondition = { sourceQuestionUid: null, answers: [] }(或 null)。
    • UI:「當 [單選題▼] 的答案是 [選項多選▼] 時顯示」+ 清除鈕。
    • 來源題下拉 radioQuestionOptionsallQuestionsFlat filter type==='radio' && id!==本題 && 非本題子題
    • 答案多選 sourceAnswerOptions:選中來源題後,列該題 options 的 {label:opt.label, value:opt.id}(id=key||name,與填答值同源)。

Task 2-3: 題組(SurveyPage)顯示條件 UI

Files: src/views/survey-v2/components/AddPageDialog.vue(或題組設定入口,grep 定位)

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

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

Task 2-5: 段② 手測 + commit(FE repo)

    1. 題目設顯示條件(選單選來源+答案)→ 存 → 重整 → badge 正確顯示。
    2. 題組設顯示條件 → 存 → 重整 → badge 正確。
    3. 母題卡「+新增延伸題」→ 建子題 → 不重整即嵌套顯示(查證 d 修復驗證)。
    4. 刪來源題 → 引用它的條件 badge 消失(段① 清理連動)。

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 略過」文案)。
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 直接掛在物件上,無需另抽
}

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

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

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

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

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

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

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 不觸發清理(回歸)/ 改題型清理
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(段②③④,開發者過一輪)


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。