建立:2026-07-23 | FR-053 母案續作(user 手測 round 51→52 後拍板四項) 相關:FR-053(三刀)、FR-050(輪次階段)、FR-038(engagement 模型)
查證:infra/grc/repository/task_execution_query.py 兩條查詢同病:
get_prep_job_progress(project_uid)(GET /grc/project/get_assigned_todo_prep_jobs(project_id)(POST start-task-execution 的來源集)—— 按 project 全域撈「已指派且 TODO」job,跨輪。第 2 刀後若母輪仍有 TODO 殘 job, 覆核輪按「開始執行任務」會把母輪 job 一起轉 PROCESSING(違反母輪零觸碰)。改法(mirror grc_project_repo_impl._resolve_dashboard_round_id 的哲學): 新增 _current_round_id(session, project_uid/project_id) = 該專案 round_no 最大輪。 round 解析到 → 兩條查詢改「wecm by round_id」起頭(round_id IS NULL 舊資料不撈, 寧缺勿錯,與第 3 刀一致);解析不到(legacy 專案無輪次)→ fallback 舊鏈(單輪 legacy 該鏈仍正確)。activate_jobs 吃上游傳入的 id 集合,本身不需改。
ReverifyInheritanceService,launch_reverify 生成後追加呼叫讀碼後排除「塞進 PrepJobGenerationService」:generate() 是純生成核心(execution/job/ wecm),塞 clone 會把 task_assignees / job_evidences / task_surveys / question_answers 四個不相干領域的寫入拉進來,且 clone 需要「母輪 job ↔︎ 新輪 job」整體對映(per-AO 迴圈 內部拿不到母輪 job 集合的批次視圖)。改為:
infra/grc/repository/reverify_clone_query.py(infra 層,唯一碰 SQL/ORM 的地方): 以 wecm (control_id, ao_part_id) 為 key 撈兩輪 job 對映,逐類 INSERT...SELECT clone。app/grc/service/reverify_inheritance_service.py(app 層 orchestrator): inherit_from_parent(new_round) — caller 已在 @transaction scope 內。audit_round_app_service._generate_reverify_prep_jobs 在 generate() 成功 (job_count>0)後呼叫。防禦哲學(比照母案):整個承襲步驟包 SAVEPOINT(session.begin_nested())—— 任何一項 clone 失敗只 rollback 承襲、log warning,不擋建輪(若不包 savepoint, aborted transaction 會連建輪一起炸)。母輪資料零觸碰:全部 INSERT...SELECT 只讀母輪。
母輪 job:wecm WHERE round_id=parent_round_id;新輪 job:wecm WHERE round_id=新輪 id。 同 (control_id, ao_part_id) 配對;新輪 key 在母輪查無(理論不會,narrowed ⊆ 母輪 scope)→ skip 該 job。
| 項目 | 表 | 做法 |
|---|---|---|
| 指派 | compliance.task_assignees(task_id=job id) |
INSERT...SELECT:task_id/task_uid/task_template_id 換新 job 值,user_id/is_approver/role 原樣,ON CONFLICT DO NOTHING(PK 冪等) |
| 部門綁定 | compliance.job_execution_org_units |
INSERT...SELECT 換 job_execution_id |
| 設備綁定 | compliance.job_execution_devices |
INSERT...SELECT 換 job_execution_id |
| 證據 | compliance.job_evidences |
逐列 clone:uid 重生(gen_random_uuid())、job/we/main_we 指新輪、file_id 同值(不複製檔案本體)、inherited_from_round_id=母輪 id、跳過 is_deleted=true;drive_file_id 置 NULL(該欄有 partial UNIQUE,同值會炸;且 Drive live-sync 簿記不應由承襲副本繼承——source 欄保留原值僅供顯示佐證來源) |
| 問卷 | survey.task_surveys |
task_id=新 job、main_project_id=新輪 main we、uid 重生、job_evidence_id 重映射到 clone 出的新 evidence id、inherited_from_round_id=母輪 id、其餘(survey_id/device_id/department_id/approval_required/status)原樣;跳過 is_delete=true |
| 填答 | survey.question_answers |
task_survey_id 重映射、uid 重生、answer/ext_answer/score/feedback 原樣 |
不 clone:job_execution_surveys(全專案無 writer、DEV 0 列,死表)、 job_execution_device_mapping(associations 模組另一套、DEV 0 列,非規劃頁綁定真身—— 規劃頁綁定真身是 job_execution_devices / job_execution_org_units, ssp_control_implementation_query.get_devices/departments_by_job_ids 實證)。
新 job 狀態:generate() 本來就 TODO 起手,不需動。
查證後 排除 source 加 'INHERITED_FROM_ROUND':
chk_job_evidences_source(只允許 SYSTEM_UPLOAD/DRIVE_SYNC),要改約束;source='DRIVE_SYNC' 篩——覆蓋掉 會失去佐證原始來源(承襲的 Drive 證據會變成不顯示 Drive 標記);→ Migration scripts/sql/2026-07-23-fr0531-inherited-from-round.sql: compliance.job_evidences + survey.task_surveys 各加 inherited_from_round_id integer(nullable soft-ref → project_audit_rounds.id, 比照專案慣例不加 FK)。task_surveys 雖在 survey schema,model 在主專案 (infra/task_survey/models/task_survey.py),question_answers 亦同—— 不涉 jedi-survey 套件源碼(套件只擁有 surveys/questions/pages),無邊界問題。 只套 DEV;STG/POC 待套(檔頭註明)。
FE 讀法:job_evidences API(JobEvidenceResponse/DTO/entity/mapper 補欄位)→ 證據清單顯示「承襲自上輪」tag。
不開新端點——規劃頁/稽核頁都已吃 GET /ssp/<uid>/control-tree,且 FR-053 第 3 刀已在 build_control_tree_by_ssp_id 解析 round(resolve_round_for_ssp)。覆核輪 (round_type='close-out' 且 parent_round_id 非空)時:
narrowed_control_ids_for 同源):parent_round → ar_result_id → oscal.assessment_findings(+ assessment_finding_risks → assessment_risks.characterizations.severity)。infra 查詢加在 SspControlImplementationQuery.get_prev_round_findings(ar_result_id)。target_id 在控制集內直接用;否則按 canonical AO key <control>_obj.N 取 _obj 前綴(memory feedback_ao_statement_key_canonical_obj_n; 現行判定粒度=控制層,target_id 即 control_id,AO 級為舊資料防禦)。prev_round_verdict: not_met(target_status_state='not-satisfied')/ met ('satisfied')/ null(其他)prev_round_findings: [{title, severity, description, run_no}](severity 取 連結 risk 的 characterizations.severity,無則 null;run_no=母輪 round_no, 供 FE PreviousFindingsPanel 直用)Route 回 raw dict(return_response(True, result) 無 marshmallow 過濾),additive 免改序列化。
prev_round_verdict==='not_met' → 紅 Tag「上輪未通過」(PrimeVue Tag severity="danger",對齊樹內既有 badge_configured Tag 用法);控制面板加「上輪判定」區塊(verdict tag + 缺失摘要清單)。fetchTreeMeta 已存 controlMeta → AuditControlRef :previousFindings 從 [] 接真值(map 至既有 PreviousFindingsPanel shape:category='deficiency'、run_no 帶入);左樹 verdict 已有本輪判定,不動。evidenceMapper passthrough inherited_from_round_id; JobExecutionDrawer / AuditControlRef 證據列加「承襲自上輪」tag(比照 DriveSourceBadge 擺位)。project-planning.json(badge/區塊標題)+ grc-shared.json (承襲 tag,兩元件共用)。DEV 實證:round 52 三個 job created 01:43(TODO 起手,updated_user=blsadmin)→ updated 01:47:39 全轉 PROCESSING。log/app.log 09:47:37(本地時間)有 POST /grc/project/c891a920.../start-task-execution + start_task_execution project=... activated=3 (assignees=1)——即 FE 規劃頁「開始執行任務」按鈕 (ProjectPlanningView.startTaskExecution),blsadmin 手按集體觸發。生成 code 確實 TODO 起手,無自動鉤子。不需修;惟同 API 的跨輪污染風險(母輪殘 TODO 會被一起轉) 併入①處理。
PYTHONPATH=. poetry run pytest test/ -q --continue-on-collection-errors)npm run build:DEV;BE 重啟 + control-tree API 200