For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 復刻 v1 跳題語意到 v2:BE(jedi-survey)持久化 flow rules → Designer 接線讀寫 → 填答端可見性引擎(純函數)驅動渲染/進度/必填檢核三消費點 → 稽核檢視端區分「不適用」與「漏答」;順手修復 D-3a 子題 Designer 接線斷鏈。
Architecture: 規則內嵌在 SurveyQuestion.options JSONB 每個選項物件(goto_question_uid / goto_page_uids 新鍵),無 schema migration。填答端用單一純函數 computeVisibility(allQuestions, allPages, flowRuleSourceQuestions, answers) → { visibleQuestionIds: Set, visiblePageIds: Set },答案任何變動即全量重算;此函數是 SurveyPreview.vue 的唯一可見性入口,currentQuestions/totalRequired/onSave 必填檢核三處全部改吃它的輸出,不得各算各的。
Tech Stack: Python (Flask + Marshmallow + SQLAlchemy, jedi-survey 套件 path dependency dev loop) / Vue 3 Composition API + PrimeVue(compliance-manager-fe)/ pytest(BE 單元測試)/ Cucumber.js + Playwright(compliance-manager-test E2E)。
✅ 三個阻塞性決策點——指揮官均已裁決(2026-07-18,原記錄於 commit 3dfdd775 message 與 post-v182 handoff §1.3,本節為正式落文),執行者依下列結論直接開工,不需再停下等待:
options 的 get_questions() 路徑(checkpoint 三處 + Task 4-1 快照讀取)補傳 locale 走 failover。TaskSurveyAnswerHistory,新增 JSONB 欄位走 sql-migration skill 鐵則寫 migration(寫好先不套,套用時機回報指揮官)。依 CLAUDE.md「plan 假設先 verify 才開工」與本次派工的五項要求,逐項在 DEV 環境對照原始碼查證(2026-07-18):
api/survey/serializers/survey_question.py:SurveyQuestionCreateRequest/UpdateRequest 的 options = fields.List(fields.Dict(), ...) —— fields.Dict() 對每個選項物件的 key 是開放型別(無 Schema 白名單),任意新鍵原樣通過 marshmallow .load()。SurveyQuestionResponse.options = fields.List(fields.Dict()) 同理,dump 時原樣吐回,新鍵不會被剝。SurveyQuestionEntity.options(domain/entities/survey_question_entity.py)是 Optional[list[Dict[str, Any]]],SurveyQuestionMapper(infra/mapper/survey_question_mapper.py)與 ORM SurveyQuestion.options(infra/models/survey_question.py,Mapped[Optional[Any]] = mapped_column(JSONB, ...))全程原樣搬運,無欄位級白名單。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),不能只靠序列化層的「開放通過」。§0-a 只驗證了序列化層/mapper 層的「欄位級透傳」,遺漏了 options 走可翻譯欄位條件式雙寫路徑的影響,經 code review 補查如下:
jedi_survey/infra/mapper/survey_question_mapper.py 的 translatable_fields 屬性含 'options';SurveyQuestionRepoImpl.__init__() 的 _setup_translation(translatable_fields=['question', 'description', 'options'], ...) 同樣把 options 列為可翻譯欄位。BaseRepositoryImpl.update()(update_with_translation())行為是條件式雙寫:
options,主表 options 不動。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 開篇要避免的坑。survey.survey_questions 主表 options 欄位,確認 goto 鍵是否真的落進主表。options 做可見性判斷的路徑(get_questions() 呼叫)都必須補上 locale 參數,確保走 failover 查詢讀到含新鍵的版本。這包含:checkpoint_task_survey_answer() 的三處 get_questions() 呼叫、Task 4-1 的稽核檢視快照讀取路徑。options 的呼叫都要重新檢查是否漏帶 locale,再繼續往下走。Task 1-4 DEV 實測結果(2026-07-18,執行者實測 + 指揮官獨立核實)— 與本節推論相反,找到真正 root cause:
對題目 id=8521(uid=c66394ac-db3e-433e-90fd-706c2fdcab86,已有 zh_Hant_TW 翻譯記錄)PUT 帶 goto_question_uid,直查 DB 發現主表與翻譯表同時寫入 goto 鍵(測試完畢已清理復原):
主表 survey.survey_questions.options: [..., "goto_question_uid": "34ea..."] ← 有寫入
翻譯表 survey.survey_questions_trans.options:[..., "goto_question_uid": "34ea..."] ← 也有寫入
Root cause 追到 jedi_common 套件(jedi_common/session/database/repository/base_repository_impl.py:628-633,update_with_translation())的既有 bug(超出本次 jedi-survey 異動範圍,非 FR-049 引入):
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,可作為回歸網)。
依 design.md §2 指示「此前提 implementation plan 時驗證,若發布後仍可改結構則改為提交時落 snapshot flag」——查證結果:確實不成立,啟用 snapshot flag 方案。
證據:
jedi-survey SurveyEntity.release(domain/entities/survey_entity.py)只是一個 int flag(0/1),domain/service/survey_domain_service.py 的 update_survey()/SurveyPageDomainService.update_page()/SurveyQuestionDomainService.update_question() 全部沒有任何 if survey.release == 1: raise ... 或類似守門。api/survey/routes/survey_route.py PUT /survey/:uid、api/survey/routes/survey_question_route.py PUT /survey-question/:uid)同樣沒有 release 狀態檢查。SurveyDesigner.vue 沒有 isReleased/canEdit/readonly 之類的欄位鎖定邏輯(grep 全檔案零命中),已發布問卷在 Designer 一樣可以編輯題目/選項/結構並存檔(onSave() 直接呼叫 updateSurvey(),沒有狀態分支)。採用方案:稽核檢視端重算不適用/漏答時,不即時拉當前 SurveyQuestion.options 規則,而是在 checkpoint_task_survey_answer(app/task_survey/service/question_answer_service.py)每次落地答案時,把當下讀到的 flow_rules 快照(從 q_uid_to_obj 已載入的 options 抽取 goto 鍵)連同 TaskSurveyAnswerHistory 一起落地一份 flow_rules_snapshot JSONB(掛在 TaskSurvey 或每筆 checkpoint 對應的 history record,取決於既有 schema,見 §3 Task 3-1 的落點抉擇)。稽核檢視重算不適用集合時,讀這份快照而非即時問卷結構,避免「規則被事後改過」造成歷史畫面漂移。 → 本項屬於 design.md 未拍板的新增資料落點,implementation 開始前必須讓指揮官確認 snapshot 掛在哪張表(見 §3 Task 3-1 的兩個選項),不可由執行者自行拍板。
allQuestions(SurveyPreview.vue:585)= collectAllQuestions(survey.value.pages),遞迴走全部 pages/questions/subQuestions,目前無任何可見性過濾。totalRequired(:590)= allQuestions.value.filter(q => q.isRequired).length —— 全量掃描,未排除被跳過題目。progress/answeredCount(:592-599)、progressItems(:601-623)同樣全量掃描 allQuestions/allPagesFlat。onSave()(:744-766):missing = allQuestions.value.filter(q => q.isRequired && !isAnswered(q.id))——目前唯一的必填檢核入口,且是 FE-only(BE checkpoint_task_survey_answer 完全沒有 required 檢查,純粹信任 FE 送來的 status transition)。currentQuestions(:563-567)決定當前頁面渲染哪些題目,尚未過濾。visibility = computed(() => computeVisibility(allQuestions.value, allPagesFlat.value, answers.value)),回傳 { visibleQuestionIds: Set, visiblePageIds: Set }。allQuestions/totalRequired/currentQuestions/onSave() 的 missing 檢查、progressItems 全部改用這個 computed 的輸出做交集過濾(必填檢核與進度計算同源,design.md §2 定案的核心要求)。三入口(/survey/preview/:id 預覽、/survey/fill 任務填答、review/history 模式)都掛同一個 SurveyPreview.vue,改一處即全生效(design.md §5 已確認的路由盤點)。AddQuestionDialog.vue 內部狀態 form.flowRules 現行形狀:{ optionId, optionLabel, targetQuestionId, targetQuestionLabel }(onSubmit(),:206-222)——單目標題(targetQuestionId 是 string,非陣列),對應 design.md 的 goto_question_uid(同題組內跳題),但完全沒有「跳題組(多選)」的欄位/UI(design.md §2 混合情境「答 3 跳 B、C 兩題組」需要的 goto_page_uids 陣列)。allQuestionsFlat(:121-137,collectQuestions())只收集題目(p.questions),沒有題組(page)清單可供「跳到題組」下拉選——需新增等價的 allPagesFlat(用既有 flattenPages from survey-mock-data.js,已在 useSurveyApi() re-export)。onSubmit() 組出的 rule 物件需擴充為區分「跳題」vs「跳題組」兩種目標型態(design.md 資料模型是兩個獨立鍵 goto_question_uid / goto_page_uids,故 UI 每條規則需要一個「目標型態」切換或分成兩個獨立區塊:單一題目下拉 + 題組 MultiSelect,MultiSelect 元件已在 main.js 全域註冊)。onQuestionSubmit()(SurveyDesigner.vue:392-440)create/update payload 完全不帶 flowRules(比對 apiCreateQuestion/apiUpdateQuestion 呼叫參數列表,只有 questionNumber/question/helpText/isRequired/weight/type/options),useSurveyApi.js 的 createQuestion()/updateQuestionApi() 對應也不處理 flowRules;survey-mapper.js 的 mapOptionToApi()(:204-212)與 mapOptionFromApi()(:112-123)都沒有 goto 鍵的映射。這與 test repo 既有 @known-bug scenario 註記一致(見 §0-e)。mapOptionToApi/mapOptionFromApi 各加兩個鍵的映射;AddQuestionDialog.onSubmit() 組出的 flowRules 改組進對應 options[].goto_question_uid/goto_page_uids(在儲存前,由 onQuestionSubmit() 或新的轉換函式把 dialog 的 flowRules 陣列攤平回各 option 物件內);useSurveyApi.createQuestion/updateQuestionApi 的 options.map() 補上這兩鍵的透傳。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 陣列是否含它。onDeleteQuestion()(SurveyDesigner.vue:442-463)、頁面刪除(AddPageDialog 觸發路徑)同樣沒有規則清理。delete_question(uid)/delete_page(uid) 執行刪除前,先掃描同問卷所有題目的 options[].goto_question_uid == uid 或 uid in options[].goto_page_uids,命中則從該 option 物件移除該鍵(不整條刪 option,只清規則)並連同該題一併 update()。此為新增邏輯,非既有 hook 可掛,需完整新寫(design.md §5「規則循環」風險段已提示要在 Designer 提示矛盾規則,但刪除孤兒規則的資料完整性清理是本查證新發現的必要範圍,design.md 未明確提及,補入 plan)。四段鏈依序開發,每段完成後必須先驗證再進下一段(BE 落地驗證 → Designer 存讀驗證 → 填答端行為驗證 → 稽核檢視驗證),因為填答端依賴 Designer 能正確寫入規則,Designer 依賴 BE 能正確持久化。D-3a 子題接線與 flow rules 接線共用同一批檔案(AddQuestionDialog.vue/survey-mapper.js/SurveyDesigner.vue),故安排在同一個 Task 一併处理,避免對同一批檔案改兩次。
Not-in-scope(design.md §5 已列 + 本次查證新增):
import_survey)不支援 flow_rules 欄位;匯入的問卷無規則,可後續在 Designer 補設。goto_question/goto_page_1/goto_page_2)不讀不寫不遷移。環境準備:本段全程在 jedi-survey 套件 path dependency 下開發(CLAUDE.md 外部套件異動規範)。
Files:
/Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/pyproject.toml:68(註解掉 pin)/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)
Files:
~/Projects/Jedicogy/module/jedi-python-package/jedi-survey/jedi_survey/domain/service/survey_question_domain_service.py~/Projects/Jedicogy/module/jedi-python-package/jedi-survey/jedi_survey/common/enum/error_code.py(新增 error code)~/Projects/Jedicogy/module/jedi-python-package/jedi-survey/tests/unittest/domain/service/test_survey_question_domain_service.py驗證規則(design.md §2/§3/§5 定案):
goto_question_uid/goto_page_uids 只能出現在 type == "radio" 的題目 options 上(單選限定;checkbox/dropdown 等禁止)。goto_question_uid 指向的 uid 必須存在(同問卷內的某題),且不能是自己所在的題目(禁止自指)。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" -vExpected: 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——查證 ISurveyPageRepo(domain/repository/survey_page.py)繼承自 IBaseRepo[T, Q],BaseRepositoryImpl 已提供通用 get_by_uid,SurveyPageRepoImpl 未覆寫代表沿用基底實作,可直接呼叫。question_entity.survey_id/SurveyPageEntity.survey_id 兩者皆為建構子必要參數,add_question()(route 層組 entity 時已帶 survey_id=page.survey_id)與 update_question()(existing = self.verify_question_exist_by_uid(...) 載入自 DB,survey_id 已存在於既有紀錄)兩條路徑都能保證此欄位有值。
pytest tests/unittest/domain/service/test_survey_question_domain_service.py -k "goto" -vExpected: 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 Nonepytest tests/unittest/domain/service/test_survey_question_domain_service.py -vExpected: 全部 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 流程規則驗證(單選限定/禁自指/目標存在性)"Files:
~/Projects/Jedicogy/module/jedi-python-package/jedi-survey/jedi_survey/domain/service/survey_question_domain_service.py~/Projects/Jedicogy/module/jedi-python-package/jedi-survey/jedi_survey/domain/service/survey_page_domain_service.pydef 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/ -vExpected: 全綠
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 群組內處理。
Files:
~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/utils/survey-mapper.jsexport 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
}Files:
~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/composables/useSurveyApi.jsD-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)
}Files:
~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/SurveyDesigner.vue~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/components/AddQuestionDialog.vueD-3a 根因確認(§0-d 已查證):onQuestionSubmit()(SurveyDesigner.vue:392-440)呼叫 apiCreateQuestion/apiUpdateQuestion 時完全沒傳 parentDbId 或 flowRules——AddQuestionDialog 的 form.parentId 存的是 FE uid(parentQuestionOptions 用 q.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")
question.flowRules?.length > 0 判斷式已存在於 SurveyDesigner.vue:723,資料補上後自動生效)Files:
~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/SurveyDesigner.vuemapQuestionFromApi()(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
}
}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)"computeVisibility 實作(composable)Files:
~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/composables/useSurveyVisibility.jsdesign.md §2 定案語意:
/**
* 純函數:依全部規則 + 全部當前答案,算出可見的題目/題組集合。
* 語意:初始/未作答 = 全可見;規則來源題答了才生效略過。
*/
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 自行推導的暫定行為,不代表已拍板的需求。
Files:
~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/SurveyPreview.vueimport { 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——這是真實的循環相依,必須拆解。修正方案:computeVisibility 的 allPages 參數改吃未過濾的原始扁平頁面清單(新增一個不受可見性影響的 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,
}
})
)design.md §2 已定案「已填而後被隱藏的答案:保留資料不刪除」——查證 answers.value 是純前端 reactive state,被隱藏題目只是不出現在 currentQuestions/allQuestions 過濾結果內,answers.value[qid] 本身未被清空,現有資料結構天然滿足此要求,不需要額外程式碼。此 Task 僅需驗證:
onTempSave()(:720-742)與 checkpoint API 呼叫走 buildPatchesFromDirty()(dirty tracking,非全量 allQuestions 掃描)——查證這條路徑跟 totalRequired/onSave 的必填檢核是分開的兩套機制,不需要改動,因為只有「使用者實際填過的欄位」才會進 patches,隱藏題目本來就不會被使用者填寫。此 Task 為確認性質,無程式碼異動。
前置條件: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 填答端可見性計算引擎——渲染/進度/必填三消費點同源"✅ 已裁決(2026-07-18 指揮官):採方案 X——快照掛每筆 TaskSurveyAnswerHistory。 新增 JSONB 欄位走 sql-migration skill 鐵則寫 migration(寫好先不套,套用時機回報指揮官)。後續 steps 由執行者依方案 X 現場展開。原兩案權衡表保留如下供脈絡(當時刻意不預展開任一方案細節,避免引導):
design.md 原文只說「此前提 implementation plan 時驗證,若發布後仍可改結構則改為提交時落 snapshot flag」,未明確二擇一。以下用中性語言並列兩案的權衡,不帶建議傾向:
方案 X:快照掛在每筆 TaskSurveyAnswerHistory |
方案 Y:快照掛在 TaskSurvey 本體 |
|
|---|---|---|
| 寫入時機 | 每次 checkpoint 都存一份 | 只在首次進入問卷時鎖定一份,後續 checkpoint 不更新 |
| 語意準確度 | 每筆歷史紀錄都對應「該次提交當下」的規則版本,與規則被事後修改的情境完全對齊 | 若規則在填答期間被 Designer 改過,快照仍是最早那份,與「提交當下」的規則版本可能不一致 |
| 資料量 | 隨 checkpoint 次數線性增長(history 表本身已隨每次提交寫入,此為既有寫入路徑上追加欄位,非新增寫入次數) | 只多一份,資料量最小 |
| 稽核檢視回溯 history 版本 | 每筆 history 各自有對應規則快照,回看任一版本歷史都能精確重算 | 所有版本共用同一份快照,無法回溯「規則在期間被改過」的情境差異 |
| 新增欄位落點 | TaskSurveyAnswerHistory(需查證是否已有 JSONB 欄位可用,或需新增) |
TaskSurvey(需查證是否已有 JSONB 欄位可用,或需新增) |
| 若需新增欄位 | 屬於 schema migration,需走 sql-migration skill |
屬於 schema migration,需走 sql-migration skill |
| 影響檔案(僅供指揮官評估落點時參考,非最終定案) | app/task_survey/service/question_answer_service.py 的 checkpoint_task_survey_answer(每次寫 history 的段落) |
同一支檔案的 checkpoint_task_survey_answer(改為只在初次寫入時落地一次),或另一個「首次進入問卷」的既有入口點(待查證) |
(以下 steps 由執行者根據方案 X 現場展開:checkpoint_task_survey_answer 每次寫 history 時,從已載入的 q_uid_to_obj options 抽取 goto 鍵落 flow_rules_snapshot JSONB;新欄位 migration 照 sql-migration 鐵則,寫好先不套。)
Files:
~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/survey-v2/SurveyPreview.vue(mode === 'view'/'history' 分支)hiddenQuestionIds 內(依 X-N 作答略過)→ 顯示「不適用(依 X-N 作答略過)」(灰色,非紅色警示)(本 Task 因依賴 Task 4-1 的落點決策,實作前同樣需等 Task 4-1 的 blocking 解除後才能精確定案渲染邏輯的資料來源,目前先給出結構性步驟。)
已於 Task 1-2/1-3 內嵌 TDD 步驟,彙整清單:
test_add_question_rejects_goto_on_non_radio_typetest_add_question_rejects_self_referencetest_add_question_allows_valid_goto_questiontest_add_question_no_goto_keys_unaffected(既有問卷零變化)test_delete_question_cleans_orphan_goto_question_refstest_delete_page_cleans_orphan_goto_page_refscd ~/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 -vExpected: 全綠,且既有測試(未涉及 goto 鍵)不受影響。
目標 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 tagsurvey-v2-designer.feature 第 170-187 行「流程規則:選項跳題設定成功並顯示於題目卡」—— 移除 @known-bug tag新增 scenario(survey-v2-designer.feature):
新增 scenario(survey-v2-fill.feature):
回歸範圍(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 內執行。
依 CLAUDE.md「收尾必須等 user 下命令才做」:完成上述所有段落並手測過關後,停下回報狀態 + 手測 checklist 結果,不主動進行:
docs/specs/ 對應頁面 spec 更新(走 writing-feature-specs skill)以上一律等使用者明確下令後才啟動對應 skill。