# FR-044 稽核紀錄 xlsx 匯入（AR import）— 設計（SD）

> Date: 2026-07-03 · Branch: `feature/FR-044`
> 前置研究（xlsx 實測、系統 AR 結構、跨框架判定狀態、決策與被排除選項）見
> [`docs/analysis/2026-07-03-ar-import-verdict-mapping.md`](../../analysis/2026-07-03-ar-import-verdict-mapping.md)
> 姊妹作：FR-043（AP docx 匯入）— 管線與 registry 同族，本 FR 將共用基建泛化
> 樣本文件：`亞航-CMMC L1內部稽核報告附件-稽核紀錄.xlsx`（v1 目標樣板）

## 1. 需求

稽核人員（auditor）或 PM 在稽核執行頁
（`/project/projects/:id/round/:roundUid/audit`，`RoundAuditReviewView.vue`）
**上傳顧問的逐條檢查紀錄 xlsx**，系統解析成 AO 判定＋觀察＋佐證連結，
user 預覽、修改、確認後批次寫入本輪 AR。

**User 拍板決策（2026-07-03，詳 analysis §5）**：
- 判定狀態用 CMMC 三態：MET / NOT MET / NOT APPLICABLE —
  **`FindingState` enum 加 `NOT_APPLICABLE`（方案 A，jedi_oscal_v2 套件異動已核准）**
- observation 粒度：**一 AO 列一筆**
- NOT MET：**自動建風險、嚴重度預設「中」**，影響／矯正建議稽核員後補

**明確不做**：
- 不自動上傳／建立佐證檔案（只連結既有 `job_evidences`／surveys；配不到列給人工）
- 不從 xlsx 推風險嚴重度（文件無此資訊，一律預設中）
- ISO 等其他框架樣板本期不實作（架構留位，見 §5）
- xlsx 表頭 meta（專案名稱/受驗證型態/人數）不落欄位，preview 顯示供核對

## 2. 欄位對應（v1 亞航檢查紀錄 → AR）

| xlsx 欄 | 系統落點 | 對應方式 |
|---|---|---|
> **粒度前提（spec review 校正）**：live 系統的判定粒度是**控制項層** —
> 一控制一筆 finding（`target_id = control_id`），AO 細節放 observation
> （2026-06-17 user 拍板，`assessment_result_app_service.py` 註記）。
> xlsx 是 AO 粒度（59 列）→ 匯入需做「AO 判定 → 控制層判定」**聚合**。

| xlsx 欄 | 系統落點 | 對應方式 |
|---|---|---|
| 索引號（`AC.L1-b.1.i`） | finding 所屬控制項（一控制一筆，`target_id=control_id`） | **系統 catalog 有兩代 id 格式**（舊：NIST 式 `AC.L1-3.1.1`×17 條；新：CMMC 2.13 AG 式 `AC.L1-b.1.i`×15 條，FR-045 之後匯入的框架）→ 對照採 **identity-first candidate resolution**：每個 AG id 的候選 = `[原樣] + AG_TO_NIST[原樣]`，取「存在於本輪 findings」者；同輪 identity 命中即不做 NIST 映射（不混用）。2026-07-04 STG 實測：v2.13 catalog 輪次被無條件 AG→NIST 轉換打成空交集、誤報框架不符（GRC_400095），此規則即該次修正。**兩代格式皆為正式支援對象（user 拍板 2026-07-04），非過渡相容：同一份顧問 xlsx 對舊 NIST 式輪次走對照表、對新 AG 式輪次走 identity，測試兩代並列常備** |
| Objectives [a]~[h] | observation（AO 明細載體，一列一筆）：description 以 `[a] <AO 原文>` 開頭 | **順序對齊**：[a]=該控制項第 1 個 AO；分母與順序**復用 canonical `derive_ao_pairs`**（`ao_derivation.py`，勿另寫讀法）；「xlsx 列數 = 系統 AO 數」不合者整控制項降級 preview 人工核對；part 順序穩定性列 pre-flight |
| 稽核結果（逐 AO） | 控制層 finding `target_status_state`（**聚合**） | 逐列先映射 MET→met、NOT MET→not_met、N/A→not_applicable、空值→pending，再按控制項聚合：**任一 not_met → `not_met`；全 not_applicable → `not_applicable`；含 pending → `pending`；其餘（全 met 或 met＋N/A 混）→ `met`**（CMMC 計分 N/A 視同 MET）。逐 AO 原始判定同時記在該列 observation（description 尾註「判定：MET」），不丟失明細 |
| Status 實作狀態 | 同列 observation：`description`＝AO＋全文＋判定尾註、`methods[]`＝前綴關鍵詞比對 | 復用 FR-043 `_METHOD_KEYWORDS`（上收共用層，見 §5）；比不出方法時 methods 留空、preview 可補 |
| Evidence 佐證資料 | observation `relevant_evidence[]`（結構同稽核頁佐證下拉：`{uid, file_uid, file_name, evidence_type…}`） | 分級配對（見 §4） |
| 表頭稽核日期 | observation `collected` | `2026年6月2日` → timestamptz；缺值 fallback 匯入當下。**需擴充主專案 `create_observation` 加 optional `collected` 參數**（現硬寫 now，屬「差一點就加參數 extend」） |
| —（聚合後 not_met 的控制項） | risk：severity=`中`、status=open、title=`{control_id} 未符合`、description=彙整該控制 NOT MET 各 AO 的判定說明；**必以 `link_risk_findings` 連回 finding**（finalize 前置 GRC_RISK_NO_FINDING_LINKED 要求每 risk 至少連一 finding） | preview 顯示「將建立中風險」徽章 |

寫入語意（**全部走既有路徑**）：每控制項 = `judge_finding`（聚合後判定）＋逐 AO 列
`create_observation`（軟連結 finding）；not_met 控制項加 `add_risk`＋`link_risk_findings`。
批次在 app service 內迴圈呼叫上述既有方法，單一 `@transaction`（全成全敗，已驗證
`@transaction` 可重入）；不繞過任何守門。

**重複匯入語意**：finding 判定＝覆寫；observation＝累加；**risk 防重**：同 control、
匯入來源（props 標記）、status=open 的既有風險 → 跳過再建。preview 頂部顯示
「本輪已有 N 個控制項判定將被覆寫」（控制層計數）。

## 3. 架構 — mirror FR-043 三段式管線

```
POST   /audit-round/<round_uid>/ar-imports/parse   上傳+解析 → {parse_uid, status}
GET    /ar-import/<parse_uid>                       預覽
POST   /ar-import/<parse_uid>/confirm               確認批次寫入
DELETE /ar-import/<parse_uid>                       廢棄
```

| 層 | 新增 | 說明 |
|---|---|---|
| route | `api/project/routes/ar_import_route.py` | project blueprint，multipart |
| serializer | `api/project/serializers/ar_import.py` | Parse/Preview/Confirm schema |
| app service | `app/grc/service/ar_import_app_service.py` | 檔案驗證（.xlsx、10MB 比照 `_EXCEL_MAX_SIZE`）→ parse job → adapter → AO 對齊＋佐證配對 → 持久化 `parsed_result` |
| adapter | 共用 import 基建（§5）＋ `airasia_cmmc_l1_ar_v1` | openpyxl/pandas 讀表 |
| domain/infra | `ar_xlsx_parse_jobs` 表（`oscal` schema、24h TTL、tenant-scoped，照 `ssp_excel_parse_job` 同形）＋ entity/repo/DI | SQL migration 走鐵則；**tenant-scoped 表必含 `tenant_id`＋`org_unit_id` 兩欄**（曾有漏 org_unit_id 導致 INSERT 500 前例） |
| 套件 | jedi_oscal_v2 **單一套件、單次異動，範圍三件事**（spec review 校正）：① `FindingState` 加 `NOT_APPLICABLE = "not_applicable"`；② `ArFindingMatrixService` 的 `_STATE_TO_TOKEN` / `_TOKEN_TO_STATE` 加對應，token 定為 `'not-applicable'`（OSCAL objective-status 無此值，比照 pending 前例存 product token）；③ `list_findings` stats 加 `na` 桶（stats 產生在套件內，非主專案） | dev 走 path dependency，發版等 user 明示 |

**`not_applicable` 的漣漪（主專案側，一次盤齊；POA&M 段依 spec review 校正）**：
- POA&M 產生點是 **`finalize_assessment`**（auditing→remediation 的 finalize，
  `assessment_result_app_service.py`；close_round 只檢查 POA&M 全 closed），掃描條件
  token `'not-satisfied'` → **N/A 用新 token 天然不會產 POA&M，此處不需改**
- 要確認的兩處：`finalize_assessment` 的 `stats["pending"] > 0` 前置（套件 na 桶加好即
  自動正確）；FE `judgedCount = met + not_met` 是否計入 na（應計入＝已判定）
- FE `verdictOptions` 加第四選項＋i18n（`verdict_not_applicable`）＋統計卡顯示 na 桶

### 權限與階段

- parse / confirm / discard：participant role ∈ {auditor, manager}。
  `_check_auditor` 是 `AssessmentResultAppService` 私有方法 — 比照 FR-043 的做法
  **把 gate 提為 public helper** 供 `ArImportAppService` 復用，不複製第二份
- 僅 round stage=`auditing` 可 parse/confirm（`assert_round_phase({"auditing"})`，
  匯入走既有寫入路徑二次繼承）
- 前提：本輪已 start-auditing（findings 框架存在）；未啟動時回 412（import 專屬新檢查，
  error code 依 `GRC_412<序號>` 命名；區別於既有 `_require_round_ar_result` 的 404）

### 錯誤碼

`grc_error_code.py` 新增，語意 mirror FR-043：格式無法辨識（400，附支援樣板清單）、
**框架不符**（400，「檔案框架 X ≠ 本輪框架 Y」）、AO 數不一致（不擋整檔，preview
降級標示）、檔案過大（400）、parse job 不存在（404）／過期（412）／非 awaiting（412）。

## 4. 佐證分級配對

配對母體＝該控制項各 AO 的 jobs → `job_evidences`（`description`＋
`upload_files.file_name`）＋ surveys，去重邏輯同 FE `obsEvidenceOptions`（同源同語意）。

正規化：一格多名先拆（換行/逗號/頓號）→ 去括號註記 → 去副檔名 → 去空白標點 →
lower-case；「(同N)」交叉引用解析為第 N 列的佐證集合。

| 級別 | 規則 | preview 行為 |
|---|---|---|
| 1 精確 | 正規化後全等 | ✅ 自動勾選 |
| 2 模糊 | token 重疊／編輯距離達門檻 | ☑️ 自動勾選＋「模糊配對」徽章（一鍵取消） |
| 3 無配對 | 未命中 | 灰列原文名；user 可從下拉手動勾；原文名一律保留在 observation description 內不丟失 |

原則：寧漏配（人工補）勿錯配；門檻與案例以樣本 88 個名稱做 fixture 測試調校。

## 5. 多框架 × 多樣板：三層架構（含 FR-043 基建泛化）

「框架知識」與「樣板長相」是兩個獨立變動軸（同框架多顧問樣板／同顧問多框架），拆三層：

```
Template adapter（每顧問樣板一支；本期 airasia_cmmc_l1_ar_v1）
  detect(file) → 表頭/欄位特徵；parse(file) → ParsedArRecord（framework 無關：
  rows[{control_ref原文, objective_seq, verdict原字串, method_text, evidence_names[]}]＋meta）
  宣告所屬 framework
        ↓
Framework profile（每框架一份設定；本期 CMMC）
  control-id 對照表（AG b.1.i ↔ catalog id）
  verdict 詞彙 → 核心四態 mapping（MET→met…；原始 verdict 字串存 finding props）
  AO 對齊策略（CMMC=逐 AO；ISO=控制項層判定，走 ao_derivation 控制層 fallback，架構已有位）
        ↓
共用 import core（parse job／AO 對齊／佐證配對／preview／confirm 批次寫入）
```

- **框架一致性檢查**：adapter 宣告的 framework ≠ 本輪 AP 綁定框架 → 400（防 ISO 檔匯進
  CMMC 輪次）
- **FR-043 基建泛化**（本 FR 內完成，屬既有 code 的針對性整併非 unrelated refactor）：
  adapter registry base 與 `_METHOD_KEYWORDS` 自 `ap_report_parser/` 上收到共用模組
  （如 `app/grc/service/import_adapter/`），AP docx registry 與 AR xlsx registry 為同
  base 兩個 instance；FR-043 行為不變（其測試為 regression 保護）
- 框架 verdict set 的 system_menu 化（FE 判定選項依框架驅動）**留待第二個框架上線時做**，
  本期 FE 只加 `not_applicable` 第四選項

## 6. FE 設計

沿用專案風格與 FR-043 wizard 模式：

- 入口：`RoundAuditReviewView.vue` header「匯入稽核紀錄」按鈕（stage=`auditing`＋
  auditor/manager＋findings 已建立時顯示；注意現有 `canEdit` 只看 stage，
  **participant role 需另取**），開 `ArImportDialog.vue` 三步 Dialog wizard
- Step 1 上傳（.xlsx、10MB）→ parse → spinner（先開 dialog 再載入慣例）
- Step 2 預覽與修改：按控制項分組（可摺疊），每 AO 列＝判定 SelectButton（四態，可改）
  ＋觀察文字（可編）＋佐證 chips（分級徽章＋下拉補勾）＋ NOT MET 列「將建立中風險」
  徽章；頂部：解析摘要（樣板版本／框架／覆寫警示／AO 不一致降級清單）；
  draft 存 localStorage（key by parse_uid）
- Step 3 完成：confirm → toast＋摘要（判定 N 筆／觀察 N 筆／風險 N 筆／佐證連結 N 筆）
  → refresh findings/stats
- 關閉前有未確認編輯二次確認；重複匯入允許（覆寫判定警示同 Step 2 頂部）

## 7. 測試重點（Phase 4 由 feature-test-planner 展開）

- adapter：樣本 59 列 snapshot（fixture 入測試專案）；AO 數不一致降級；「(同N)」解析；
  一格多名拆分
- 佐證配對：88 名稱 fixture 的分級判定（精確／模糊／無配對各取代表案例，含 l/I 打字錯）
- **聚合規則**：全 met／任一 not_met／全 na／met+na 混（→met）／含 pending 五種組合
- app service：權限矩陣×parse/preview/confirm、stage 守門（非 auditing 412）、
  double-confirm 412、TTL 412、confirm 全成全敗（transaction rollback）、
  聚合 not_met 建風險 severity=中＋`link_risk_findings` 已連（finalize 不被
  GRC_RISK_NO_FINDING_LINKED 擋）、**重複匯入 risk 防重不重複建**、
  `not_applicable` token 不產 POA&M（finalize_assessment 掃描驗證）、stats na 桶
- FR-043 regression：registry 泛化後 AP docx 匯入測試全綠
- FE e2e（compliance-manager-test repo）：上傳→改判定→確認→稽核頁狀態更新

## 8. 未來擴充（本期不做）

- ISO 27001 檢查紀錄樣板（框架 profile＋控制項層判定；verdict set system_menu 化同期）
- PCI「Not Tested」獨立統計桶評估（analysis §7 反悔條件）
- AR 匯出（系統資料反向產檢查紀錄表）

## 9. 實作校正與 STG 修正（收尾 2026-07-04，✓ shipped）

> 本節記錄「設計 → 實作 → STG 實測」過程中與上文的差異與補強。上文 §1–§8 是設計初稿，
> 下列為**實作實況與五個 STG 實測 root cause 修正**。決策軌跡見
> `docs/analysis/2026-07-04-fr044-stg-bugfix-decisions.md`。

**已建交付**：Phase A–F 全數落地（純函式 → adapter → framework profile → app service →
route/serializer/DI → FE 三步 wizard + 佐證分級配對 UI）。套件 jedi-oscal-v2 **2.2.0** 已推
Nexus（含 `FindingState.NOT_APPLICABLE` + matrix `na` 桶；該 release 同時含 FR-045 v2.13 PDF
parser，user 拍板一起出）。DEV + STG DB 已套 `ar_xlsx_parse_jobs` migration。

**五個 STG 實測修正（皆與上文設計有出入，已修正並補測）**：

| # | 症狀（STG） | root cause | 修法（採用） | 被排除選項 | commit |
|---|---|---|---|---|---|
| 1 | v2.13 catalog 輪次匯入誤報框架不符 GRC_400095 | 系統 catalog 有**兩代 control_id 格式共存**（NIST `AC.L1-3.1.1`×17 / AG `AC.L1-b.1.i`×15）；原無條件 `AG_TO_NIST` 轉換對 v2.13 交集為空 | **identity-first candidate resolution**（見 §2 已更新）；兩代皆正式支援；v2.13 下 `PE.L1-b.1.ix` 不再一對三拆分（AO 數同 xlsx） | 「只支援一代 / 強制轉一代」（會逼既有輪次洗資料） | `72d377fb` |
| 2 | preview「解析出 0 項」，`GET /ar-import/<uid>` 回 `data:null` | `common/util/response_util.py:return_response` 對 payload **頂層含 `meta` key** 有歷史魔法分支，拆成 `{data, meta}` 丟棄 controls 等欄位（AP docx 無 meta key 故沒事） | app service 回傳 `meta`→**`report_meta`**（+ serializer + FE 對齊）；陷阱登記 `docs/claude/domain-capabilities.md` | 「改 `return_response` 本體」（全專案多 route 依賴該分頁 meta 分支，全域 breaking） | `43c3c4d8` / FE `16b7931` |
| 3 | 佐證勾選了但稽核頁顯示不出已連結 | **佐證池跨控制汙染**：同檔名證據被上傳到多個控制，走 control-tree 的池把別控制同名版本吸進來、matcher 照名字誤配 → 存了不屬於該控制的 evidence uid | 用權威 `public.workflow_execution_control_mapping` **二次 scope**（只留 wf 真正 map 到該控制的證據）；`file_id/drive_url/file_size` shape 補齊 | 「改 control-tree 產生邏輯」（會牽動稽核頁 obsEvidenceOptions，風險大） | `766d8a61` |
| 4 | 點佐證預覽「查無檔案」 | 匯入把**數字 `file_id` 誤存進 `file_uid`**，但預覽端點 `PDF_FILE_PREVIEW/{uid}` 需 upload_files **字串 uid** | `file_uid` 存 `ev.file.uid`（字串）、`file_id` 另存數字、`file_size` 用 `.size` | — | `85b9a1e9` |
| 5 | 重匯一直疊重複觀察 | observation 設計為累加（給稽核員長期加），但**匯入重傳應「最新覆蓋」** | parse_job `import_summary` 追蹤本次 `observation_uids`，confirm 前 best-effort 清掉上一批**匯入來源**觀察（手動觀察不動） | 「掃描 findings 全清」（會誤刪手動觀察）；「加 observation source 欄位」（schema 異動，過重） | `f58701ad` |

**§2 line 52「重複匯入語意 observation＝累加」已被 #5 取代** → 正確語意為「匯入觀察=最新覆蓋、手動觀察=累加」。

**佐證分級配對（§4 補強）**：池組裝＝`build_control_tree_by_ssp_id → 控制 AO → jobs →
`get_job_evidences_by_job_execution_uid` + surveys`，再經 #3 的 wf 權威 scope 去汙染；matcher
（`ar_import/evidence_matcher.py`）分級 exact/fuzzy/none + `(同N)` marker；FE `ArImportDialog`
渲染分級徽章 + 自動勾選 + confirm 送 `relevant_evidence`。**「無配對從完整池手動下拉補勾」本期未做**（列 follow-up）。

**已知 follow-up**：POC DB migration 未套；所有 git commit 未 push（等 user）；佐證 manual-from-full-pool 未做。
