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 的唯一可見性入口,currentQuestionstotalRequiredonSave 必填檢核三處全部改吃它的輸出,不得各算各的。

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 翻譯路徑實測(此為執行步驟非待裁決項);若確認規則落翻譯表,填答端所有讀 optionsget_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.pySurveyQuestionCreateRequest/UpdateRequestoptions = fields.List(fields.Dict(), ...) —— fields.Dict() 對每個選項物件的 key 是開放型別(無 Schema 白名單),任意新鍵原樣通過 marshmallow .load()
  • SurveyQuestionResponse.options = fields.List(fields.Dict()) 同理,dump 時原樣吐回,新鍵不會被剝。
  • jedi-survey SurveyQuestionEntity.optionsdomain/entities/survey_question_entity.py)是 Optional[list[Dict[str, Any]]]SurveyQuestionMapperinfra/mapper/survey_question_mapper.py)與 ORM SurveyQuestion.optionsinfra/models/survey_question.pyMapped[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.pytranslatable_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=8521uid=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-633update_with_translation())的既有 bug(超出本次 jedi-survey 異動範圍,非 FR-049 引入):

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.releasedomain/entities/survey_entity.py)只是一個 int flag(0/1),domain/service/survey_domain_service.pyupdate_survey()SurveyPageDomainService.update_page()SurveyQuestionDomainService.update_question() 全部沒有任何 if survey.release == 1: raise ... 或類似守門
  • 主專案 route 層(api/survey/routes/survey_route.py PUT /survey/:uidapi/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_answerapp/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 現行可見性/進度/必填三處位置 — 已定位,皆為單一小面積改點 ✅

  • allQuestionsSurveyPreview.vue:585)= collectAllQuestions(survey.value.pages),遞迴走全部 pages/questions/subQuestions,目前無任何可見性過濾
  • totalRequired:590)= allQuestions.value.filter(q => q.isRequired).length —— 全量掃描,未排除被跳過題目。
  • progressansweredCount:592-599)、progressItems:601-623)同樣全量掃描 allQuestionsallPagesFlat
  • 必填檢核(提交/關閉守門)在 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 }allQuestionstotalRequiredcurrentQuestionsonSave() 的 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-137collectQuestions())只收集題目(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.jscreateQuestion()/updateQuestionApi() 對應也不處理 flowRulessurvey-mapper.jsmapOptionToApi():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/updateQuestionApioptions.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 == uiduid 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.vuesurvey-mapper.jsSurveyDesigner.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 註解)
jedi-survey = { path = "/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-survey", develop = true }
python3 -c "import jedi_survey; print(jedi_survey.__file__)"

Expected: 路徑指向 ~/Projects/Jedicogy/module/jedi-python-package/jedi-survey/jedi_survey/...(非 site-packages)

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。
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")
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 未拋出,因為驗證邏輯還沒寫)

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")

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

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——查證 ISurveyPageRepodomain/repository/survey_page.py)繼承自 IBaseRepo[T, Q]BaseRepositoryImpl 已提供通用 get_by_uidSurveyPageRepoImpl 未覆寫代表沿用基底實作,可直接呼叫。question_entity.survey_idSurveyPageEntity.survey_id 兩者皆為建構子必要參數,add_question()(route 層組 entity 時已帶 survey_id=page.survey_id)與 update_question()existing = self.verify_question_exist_by_uid(...) 載入自 DB,survey_id 已存在於既有紀錄)兩條路徑都能保證此欄位有值。

pytest tests/unittest/domain/service/test_survey_question_domain_service.py -k "goto" -v

Expected: PASS

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
pytest tests/unittest/domain/service/test_survey_question_domain_service.py -v

Expected: 全部 PASS

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 檔
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]
pytest tests/unittest/domain/service/test_survey_question_domain_service.py -k "orphan" -v
@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)
pytest tests/unittest/domain/service/ -v

Expected: 全綠

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 實測,結論見上方)


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
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 : [],
    }
}
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
}
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),不是讀取路徑。

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。

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)
}

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 時完全沒傳 parentDbIdflowRules——AddQuestionDialogform.parentId 存的是 FE uid(parentQuestionOptionsq.id),需要轉成 _dbId 才能餵給 createQuestion()pid

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 部分。

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,
    })
}
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)
    }
}
function addFlowRule() {
    form.value.flowRules.push({
        optionId: '',
        optionLabel: '',
        targetQuestionId: '',
        targetQuestionLabel: '',
        targetPageIds: [],
        targetPageLabels: [],
    })
}
<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" />
"jump_to_pages": "跳轉至題組(可複選)",
"question_group": "題組"

(EN 對應:"Jump to pages (multi)" / "Question group"

    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)跑一次重組:

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
    }
}

Task 2-5: Commit(主專案 + FE repo 分開 commit)

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:該題組集合以外、同層級「被任一規則提及的題組」不可見;未被提及的題組不受影響。
/**
 * 純函數:依全部規則 + 全部當前答案,算出可見的題目/題組集合。
 * 語意:初始/未作答 = 全可見;規則來源題答了才生效略過。
 */
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 自行推導的暫定行為,不代表已拍板的需求

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

Files:

  • Modify: ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/SurveyPreview.vue
import { computeVisibility } from './composables/useSurveyVisibility'
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
}
const visibility = computed(() => {
    if (!survey.value) return { visibleQuestionIds: new Set(), visiblePageIds: new Set() }
    return computeVisibility(allQuestions.value, allPagesFlat.value, answers.value)
})
const totalRequired = computed(() =>
    allQuestions.value.filter((q) => q.isRequired && visibility.value.visibleQuestionIds.has(q.id)).length
)
const answeredCount = computed(() => {
    return allQuestions.value
        .filter((q) => visibility.value.visibleQuestionIds.has(q.id))
        .filter((q) => isAnswered(q.id)).length
})
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)
})
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)
})
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——這是真實的循環相依,必須拆解。修正方案:computeVisibilityallPages 參數改吃未過濾的原始扁平頁面清單(新增一個不受可見性影響的 allPagesRaw computed,只做 flattenPagesWithDepth),allPagesFlat(給 UI 導覽用)則是 allPagesRaw 過濾 visibility.visiblePageIds 後的結果:

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 改為上述過濾版本。)

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 邏輯不變
    }
}
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 僅需驗證:

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

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

Task 3-5: Commit

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

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.pycheckpoint_task_survey_answer(每次寫 history 的段落) 同一支檔案的 checkpoint_task_survey_answer(改為只在初次寫入時落地一次),或另一個「首次進入問卷」的既有入口點(待查證)

(以下 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.vuemode === 'view'/'history' 分支)
    • 若題目在 hiddenQuestionIds 內(依 X-N 作答略過)→ 顯示「不適用(依 X-N 作答略過)」(灰色,非紅色警示)
    • 若題目不在隱藏集合但未答 → 維持既有紅色「漏答」提示

(本 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
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(段②③完成後,開發者自行過一輪)


7. 收尾(等 user 明確下令,本 plan 不預先執行)

依 CLAUDE.md「收尾必須等 user 下命令才做」:完成上述所有段落並手測過關後,停下回報狀態 + 手測 checklist 結果,不主動進行:

  • jedi-survey 套件發版(path dependency 還原 pin)
  • docs/specs/ 對應頁面 spec 更新(走 writing-feature-specs skill)
  • Notion / memory / SUMMARY 等收尾類文件

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