FR-053.1 覆核輪承襲與脈絡 — Design(精簡版)

建立:2026-07-23 | FR-053 母案續作(user 手測 round 51→52 後拍板四項) 相關:FR-053(三刀)、FR-050(輪次階段)、FR-038(engagement 模型)

§1

① task-execution 統計補輪次(第 3 刀漏網 bug)

查證:infra/grc/repository/task_execution_query.py 兩條查詢同病:

  • get_prep_job_progress(project_uid)(GET /grc/project//task-execution/progress, FE 規劃頁「證據蒐集執行進度」)——走 catalog→AO props→wt→we→je 舊鏈。props 永遠指 第一輪 cloned template(覆核輪 per-round clone 不回填 props)→ 覆核輪永遠顯示第一輪 的 100%。DEV 實證:project 296 round 52(3 job PROCESSING)打 progress 回 total=3/completed=3(全是 round 51 的)。
  • 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 集合,本身不需改。

§2

② 覆核輪 clone 配置+證據+問卷(核心)

架構選擇:獨立 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。

Clone 項目

項目 表 做法
指派 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 新值

查證後 排除 source 加 'INHERITED_FROM_ROUND':

  1. DB 有 CHECK chk_job_evidences_source(只允許 SYSTEM_UPLOAD/DRIVE_SYNC),要改約束;
  2. source 語意是「上傳來源」,Drive sync 整條管線按 source='DRIVE_SYNC' 篩——覆蓋掉 會失去佐證原始來源(承襲的 Drive 證據會變成不顯示 Drive 標記);
  3. 承襲是「跨輪關係」不是「來源」,正交欄位才對。

→ 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。

§3

③ 上輪脈絡(BE 併入 control-tree + FE 兩頁)

BE:併入 control-tree response(additive)

不開新端點——規劃頁/稽核頁都已吃 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 非空)時:

  • 母輪 AR 讀取複用既有鏈(與 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)。
  • finding→控制對位:target_id 在控制集內直接用;否則按 canonical AO key <control>_obj.N 取 _obj 前綴(memory feedback_ao_statement_key_canonical_obj_n; 現行判定粒度=控制層,target_id 即 control_id,AO 級為舊資料防禦)。
  • 控制節點新欄(additive,凍結輪/初始輪/living 非覆核輪一律 null/[]):
    • 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 免改序列化。

FE

  • 規劃頁 ProjectPlanningView:左樹控制節點 prev_round_verdict==='not_met' → 紅 Tag「上輪未通過」(PrimeVue Tag severity="danger",對齊樹內既有 badge_configured Tag 用法);控制面板加「上輪判定」區塊(verdict tag + 缺失摘要清單)。
  • 稽核頁 RoundAuditReviewView:fetchTreeMeta 已存 controlMeta → AuditControlRef :previousFindings 從 [] 接真值(map 至既有 PreviousFindingsPanel shape:category='deficiency'、run_no 帶入);左樹 verdict 已有本輪判定,不動。
  • 證據承襲標記:evidenceMapper passthrough inherited_from_round_id; JobExecutionDrawer / AuditControlRef 證據列加「承襲自上輪」tag(比照 DriveSourceBadge 擺位)。
  • i18n:zh-tw/en 補 project-planning.json(badge/區塊標題)+ grc-shared.json (承襲 tag,兩元件共用)。
§4

④ PROCESSING 起手查證 — 根因:user 手動觸發,正常功能,不改

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 會被一起轉) 併入①處理。

§5

驗證

  1. pytest 全套 before/after FAILED 集合零 diff(baseline 本 HEAD:107 failed/836 passed/50 errors;跑法 PYTHONPATH=. poetry run pytest test/ -q --continue-on-collection-errors)
  2. DEV rollback 式煙測:tx 內先刪 round 52 全鏈(合成前置)→ launch_reverify(round 51) → 驗新 job 指派/證據(承襲標記)/問卷+填答 clone、狀態 TODO、母輪零變動 → rollback
  3. ① 驗證:round 52 專案打 progress → 回覆核輪數字(3 in_progress)非第一輪 100%
  4. FE npm run build:DEV;BE 重啟 + control-tree API 200