# FR-053.1 覆核輪承襲與脈絡 — Design（精簡版）

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

## ① task-execution 統計補輪次（第 3 刀漏網 bug）

**查證**：`infra/grc/repository/task_execution_query.py` 兩條查詢同病：

- `get_prep_job_progress(project_uid)`（GET /grc/project/<uid>/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 集合，本身不需改。

## ② 覆核輪 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。

## ③ 上輪脈絡（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，兩元件共用）。

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

## 驗證

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
