Phase A5 Design — 預覽 UI + Confirm 寫入(含 inline 新建)

Phase:A5(Track A:Excel 匯入第五 / 收口階段) 級別:中型(design + plan,不獨立 brainstorm) 狀態:design draft(2026-05-20,A4 ship 後接續開工) 前置:A1(樣板下載)+ A2(Parser + 解析 API)+ A3(共用 matcher)+ A4(5 reconciler + 5 WriteStrategy + 8-step pipeline)皆 BE shipped 本 phase 解鎖:Track A Excel 匯入 user-facing 端到端完整可用


1. 為什麼要做 A5

1.1 既有現況(A4 ship 後)

A4 phase 完成 5 個新 reconciler(device / info_system / leveraged / catalog_control / AO)+ 5 個 WriteStrategy + SSP shell + 8-step pipeline + DI 完整化。Excel 匯入 confirm flow 從 A2 skeleton(只寫 parties)升級到完整寫入(parties + SSP shell + control_implementations + control_impl_objectives + 3 種 ssp_system_implementation_items)。

A4 Step 7(_update_parsed_result_with_reconcile)已把 reconcile 結果(matched_*_id / match_method / match_confidence)by-key 對齊回填到 parsed_result JSONB。A5 預覽 UI 只要讀這份 JSONB 即可呈現完整鉤稽結果。

A4 留下的 5 個 stub / 缺口給 A5 補

區塊 A4 ship 後 A5 待補
Confirm payload schema SspExcelImportConfirmRequestSchema.decisionsList[Dict]overridesDict(彈性,A5 定稿) 結構化 schema:decisions[] per entity + inline_creates[] per entity + content_overrides{}
Unmatched 處理路徑 BE 寫純文字 (FK=null) 一條 path 三種路徑:(a) 選現有 entity / (b) inline 新建 tenant 資料 / (c) 純文字保留
Inline 新建 不存在 confirm @transaction 內 BE 先寫 public.devices / compliance.information_systems / org_units / users → 拿 id → 寫 SSP item with FK
DELETE endpoint 不存在 DELETE /ssp-excel-import/<parse_uid> 對齊 docx discard_parse pattern
FE 預覽頁 不存在 /module-frame/import-excel 路由 + Vue 元件 + Pinia store + localStorage 暫存

1.2 A5 三個目標

  1. BE confirm schema 定稿 — 處理 unmatched 三種路徑 + content_overrides + decisions per entity
  2. BE inline 新建 handling — confirm @transaction 內 atomically 寫 tenant 表 → 拿 id → 寫 SSP item with FK
  3. FE 預覽頁 + 編輯介面 — 9 sheet tab 展示 + unmatched 三種 UI 路徑 + fuzzy badge + localStorage 暫存 + i18n

1.3 與其他 phase 關係

  • 依賴 A4:parsed_result JSONB 已含 matched_*_id / match_method / match_confidence;A5 FE 直接讀
  • 依賴 A2GET /ssp-excel-import/<parse_uid> endpoint + parsed_result 結構;A5 不改 endpoint shape,只 polish response
  • 依賴 jedi-oscal:不動(A4 ship 後 path-dep 不再變動)
  • 解鎖 Track A 完整:A5 ship 後 Excel 匯入端到端可用,user 從上傳 → 預覽 → 拍板 → confirm → SSP 寫入完整

2. 範圍 / 不在範圍

2.1 In Scope(A5 task arc 內)

BE

  • SspExcelImportConfirmRequestSchema 完整 schema 定稿(decisions / inline_creates / content_overrides)
  • SspExcelImportAppService.confirm_import 增加 inline_creates handling(在 _write_all_data 之前先寫 tenant 表 + 回填 matched_*_id)
  • DELETE /ssp-excel-import/<parse_uid> endpoint(soft delete parse job)
  • _update_parsed_result_with_overrides helper — confirm 時把 user 編輯後的 content_overrides 蓋掉 parsed_result 對應欄位
  • Inline 新建可寫的 4 種 tenant 表:public.devices / compliance.information_systems / org_units / users(最後 2 個透過 jedi-auth domain service)

FE

  • /module-frame/import-excel 路由 + Vue 元件骨架
  • 預覽元件:9 sheet tab 展示(00_說明 + 01-08 資料 sheet)
  • Unmatched UI 三路徑:
    • (a) 從下拉選現有 entity(候選來自 GET tenant 表)
    • (b) Inline 新建 dialog(fill in tenant 表必填欄位)
    • (c) 保留純文字(不配對,FK=null)
  • Fuzzy badge UI:confidence < 1.0 顯示「模糊匹配」+ confidence 數值 + override 按鈕
  • localStorage dirty state:user 編輯後 confirm 前不丟失
  • Confirm flow + discard flow + i18n(zh-tw / en)

Test

  • BE pytest:confirm schema validation / inline_creates path / DELETE endpoint / content_overrides 套用
  • 不寫 cucumber(沿 F-A4-cucumber pattern;env 配齊那天一次補)

2.2 Out of Scope(列 follow-up)

# 項目 為什麼不做 / 何時做
F-A5-cucumber E2E cucumber regression env 配齊時一次性補 A1-A5 共 5 phase
F-A5-preview-export 預覽頁直接匯出 partial Excel 跟 Track B SSP 匯出有重疊,留 B6 統一處理
F-A5-batch-import 批次上傳多個 Excel Track A 目前單檔模式,批次另起 phase 評估
F-A5-template-version-warn 上傳舊版 template 警告 既有 template_version 已存在 parsed_result,FE 可後 polish

2.3 不在 scope 的事(明確排除)

  • ❌ 不改 A4 ship 的 reconciler / WriteStrategy / pipeline 行為
  • ❌ 不擴 A4 5 entity 以外的 inline 新建(如 catalog_control / assessment_objective — 這些不存 tenant 表,UNMATCHED 直接 skip 是 A4 拍板)
  • ❌ 不改 jedi-oscal 套件
  • ❌ 不在 BE 主專案內加 e2e(一律走 compliance-manager-test repo)

3. Architecture

3.1 BE 改動 file 清單

api/oscal/routes/ssp/
└── ssp_excel_import_route.py            # M:加 DELETE method 到 SspExcelImportRoute

api/oscal/serializers/ssp/
└── ssp_excel_import.py                  # M:擴 ConfirmRequestSchema(decisions / inline_creates / content_overrides)

app/oscal/service/
└── ssp_excel_import_app_service.py      # M:加 confirm_import payload 處理 + _apply_inline_creates + _apply_content_overrides + discard_parse method

common/code/
└── grc_error_code.py                    # M:加 inline 新建相關 error code(如 GRC_400xxx 必填缺漏 / GRC_409xxx 重複 name)

不新建 file(中型 phase,全 in-place 擴)。

3.2 FE 改動 file 清單

src/views/module-frame/
├── ImportExcel.vue                       # 新:路由 entrypoint
└── components/import-excel/              # 新:本 phase 元件子目錄
    ├── ImportExcelPreview.vue            # 9 sheet tab shell
    ├── SheetPreviewBasic.vue             # 01_基本資料
    ├── SheetPreviewParties.vue           # 02_單位 + 03_參與人員(共用)
    ├── SheetPreviewDevices.vue           # 04_設備
    ├── SheetPreviewInfoSystems.vue       # 05_資訊系統
    ├── SheetPreviewLeveraged.vue         # 06_外部利用服務
    ├── SheetPreviewControls.vue          # 07_控制項與 AO
    ├── SheetPreviewRefDocs.vue           # 08_程序書
    ├── UnmatchedRow.vue                  # 共用:unmatched 三路徑 UI
    ├── InlineCreateDialog.vue            # 共用:新建 dialog(4 種 entity)
    └── FuzzyBadge.vue                    # 共用:fuzzy 標示 + override 按鈕

src/store/
└── modules/sspExcelImport.js             # 新:Pinia store(parsed_result / localStorage / inline_creates 暫存)

src/service/oscal/
└── SspExcelImportService.js              # 新:BaseService extension(parse / get / update / confirm / discard)

src/router/
└── routes.js                             # M:加 /module-frame/import-excel 路由

src/locales/
├── zh-tw/module-frame.js                 # M:i18n
└── en/module-frame.js                    # M:i18n

3.3 BE App service 內部結構(confirm_import 新流程)

A5 後 SspExcelImportAppService.confirm_import 流程:

confirm_import(parse_uid, payload, user_context)
│
├── 1. Validate payload (decisions / inline_creates / content_overrides schema)
├── 2. Load parse job + parsed_result
├── 3. _apply_content_overrides(parsed_result, content_overrides)
│       └── 蓋掉 parsed_result 對應欄位(基本資料 / parties / controls 內容)
├── 4. _apply_inline_creates(inline_creates, user_context, tenant_id)   ← A5 新增
│       ├── For each inline_create.entity_type:
│       │   ├── device      → device_domain_service.add(...)
│       │   ├── info_system → information_system_domain_service.add(...)
│       │   ├── org_unit    → jedi-auth org_unit_service.add(...)
│       │   └── user        → jedi-auth user_service.add(...) + send_invite
│       └── 回 dict {row_idx: new_entity_id}
├── 5. _apply_decisions(parsed_result, decisions)                       ← A5 新增
│       └── 把 decisions[].matched_*_id 蓋掉 parsed_result(user 預覽頁手動改的)
│       └── 把 decisions[].skip=True 標 unmatched(純文字保留)
├── 6. _write_all_data(...)   ← A4 ship,A5 不改
│       └── 8-step pipeline 內部走 A4 reconciler + WriteStrategy
└── 7. Return import_summary (含 inline_created_counts / decision_applied_count)

整段在 @transaction 內 — inline 新建 + SSP 寫入要 atomic(user 取消預覽不留髒資料)。

3.4 DDD 規範

  • Route 層只解 payload + dispatch app service(不查 DB)
  • App service confirm_import@transaction
  • Inline 新建走 domain service.add(),不直接 import ORM model
  • 權限檢查:寫入 confirm endpoint 必有 manager / auditor 角色檢查(沿用既有 ssp_excel_import 入口的權限規則 — A2 已實作 + 沿用)

4. Data Structures

4.1 Confirm Request Schema(A5 定稿)

class InlineCreateSchema(Schema):
    """user 在預覽頁點 '新建' 後 BE 要寫的 tenant entity"""
    row_idx = fields.Int(required=True)           # parsed_result 內第幾筆(給回填用)
    entity_type = fields.Str(required=True, validate=validate.OneOf([
        "device", "information_system", "org_unit", "user"
    ]))
    payload = fields.Dict(required=True)          # tenant 表必填欄位(依 entity_type 不同)

class DecisionSchema(Schema):
    """user 對單筆 parsed entity 的最終決定"""
    sheet = fields.Str(required=True)             # devices / info_systems / leveraged / parties / controls
    row_idx = fields.Int(required=True)
    action = fields.Str(required=True, validate=validate.OneOf([
        "use_matched",      # 用 reconciler 配到的 matched_*_id(或 user 改過的)
        "use_existing",     # user 從下拉選了另一個 existing entity
        "use_inline_new",   # user 新建(透過 inline_creates 流程)
        "skip_as_text"      # 純文字保留(FK=null)
    ]))
    matched_id_override = fields.Int(load_default=None)   # use_existing 時帶
    inline_create_row_idx = fields.Int(load_default=None) # use_inline_new 時對應 inline_creates 內第幾筆

class SspExcelImportConfirmRequestSchema(Schema):
    decisions = fields.List(fields.Nested(DecisionSchema), load_default=list)
    inline_creates = fields.List(fields.Nested(InlineCreateSchema), load_default=list)
    content_overrides = fields.Dict(load_default=dict)

content_overrides shape(沿 docx pattern):

{
  "metadata": {"name": "<new>", "abbreviation": "<new>", ...},
  "controls": {
    "AC-1": {
      "implementation_description": "<new>",
      "objectives": {"(a)": "<new>", "(b)": "<new>"}
    }
  }
}

4.2 Confirm Response Schema 擴張

class SspExcelImportConfirmResponseSchema(Schema):
    parse_uid = fields.Str()
    status = fields.Str()                                 # "success" / "partial" / "failed"
    module_frame_uid = fields.Str(allow_none=True)
    profile_uid = fields.Str(allow_none=True)
    import_summary = fields.Dict()                        # A4 ship 既有 + 加 inline_created_* 計數

import_summary 新增欄位(A4 ship 既有計數之外):

{
  "parties_written": 5,
  "controls_written": 32,
  ...
  "inline_created_devices": 2,
  "inline_created_info_systems": 1,
  "inline_created_org_units": 0,
  "inline_created_users": 0,
  "decision_applied_count": 3,
  "skipped_as_text_count": 1
}

4.3 FE parsed_result 編輯 state(localStorage)

// key: `ssp-excel-import:${parse_uid}`
{
  "version": 1,
  "saved_at": "2026-05-20T10:00:00Z",
  "parsed_result_overlay": {
    "metadata": { ... },
    "controls": { ... }
  },
  "decisions": [
    { "sheet": "devices", "row_idx": 0, "action": "use_existing", "matched_id_override": 42 }
  ],
  "inline_creates": [
    { "row_idx": 1, "entity_type": "device", "payload": { "name": "Web-02", "ip": "10.0.0.1" } }
  ]
}

TTL:跟 BE parse job TTL 對齊(7 天)。discard parse 時清掉。


5. User Mental Walkthrough(強制段,per SOP)

5.1 主流程(happy path)

1. user 進入 /module-frame/import-excel 頁面
   → 看到 "上傳 Excel" 按鈕 + 既有 framework + version 兩層 picker
2. user 選 framework + version → 上傳 .xlsx
   → POST /ssp-excel-imports/parse
   → BE 回 parse_uid + status='success' (or 'failed' + error_code)
   → 自動 redirect /module-frame/import-excel/<parse_uid>
3. user 看到 9 個 sheet tab(00_說明 + 01-08)
   → tab badge 顯示 unmatched 數量(如 04_設備 [3 unmatched])
   → 預設停在 01_基本資料
4. user 編輯欄位(基本資料 / 程序書 / 控制項 impl 文字)
   → 即時存 localStorage(無 BE 呼叫,避免 race)
   → 重新整理頁面 → state 恢復
5. user 進 04_設備 tab
   → 看到 5 列:3 列已配對(綠勾 + matched device 名稱)/ 2 列 unmatched(紅 badge)
   → 2 列 fuzzy(confidence 0.7 黃 badge "模糊匹配" + override 按鈕)
6. user 對 unmatched device 點「選現有」
   → opens dropdown (autocomplete from tenant.devices)
   → user 選 → 該列 matched_id_override 寫 localStorage decisions
7. user 對另一 unmatched device 點「新建」
   → opens InlineCreateDialog (form: name / ip / os / device_type / status)
   → user 填完 → 暫存到 localStorage inline_creates
   → 該列顯示 "已新建 + Web-02"(淡藍 badge,confirm 後才真的寫 DB)
8. user 對 fuzzy match 點「override」
   → 可選 "改選現有" / "改新建" / "純文字保留" / "確認此模糊匹配"
   → default action: 確認此模糊匹配 (use_matched)
9. user 巡完 9 個 sheet → 點 "確認匯入" 按鈕
   → opens 確認 modal: 顯示總計(X 配對 / Y 新建 / Z 純文字 / W 跳過)
   → user 點 "確定"
   → POST /ssp-excel-import/<parse_uid>/confirm with decisions + inline_creates + content_overrides
   → BE @transaction:
     1. 套 content_overrides
     2. 寫 inline_creates 到 tenant 表 → 拿 id
     3. 套 decisions(matched_id_override / inline_create_row_idx 對應)
     4. _write_all_data (A4 8-step)
     5. 返 import_summary
   → FE 收到 success → 清 localStorage → redirect /module-frame/<module_frame_uid> 顯示成功 toast

5.2 錯誤路徑

情境 UI 行為
上傳 .xlsx 結構錯誤(缺 sheet / 缺欄位) parse 回 status='failed' + error_code GRC_400xxx;FE 顯示「樣板版本不符」+ 重新上傳按鈕
Inline 新建 device.name 重複 confirm 整段 rollback;FE 跳 modal 顯示「設備名稱 'Web-02' 已存在」+ 引導 user 選現有
Confirm 撞 SSP 已存在(update flow 缺 SSP) BE raise GRC_412026;FE 跳 modal「對應的 MF 尚未建立 SSP,請先 ...」
User 離開頁面(瀏覽器關閉) localStorage 保留;下次回到此 parse_uid 自動恢復 + 顯示「上次編輯於
Parse job TTL 過期 GET /ssp-excel-import/<parse_uid> 回 404;FE 顯示「解析資料已過期,請重新上傳」+ 清 localStorage

5.3 跨角色情境

  • Manager 上傳 + 編輯 + confirm:完整流程
  • Auditor 上傳 + 編輯 + confirm:同 manager 流程(A2 既有權限規則)
  • Viewer / 無權限:route 入口直接 403(沿 A2 既有 jwt + role check)

6. Acceptance Criteria

6.1 BE Acceptance

# AC 驗證方式
BE-1 Confirm payload schema 接 decisions / inline_creates / content_overrides;錯誤格式回 400 + error_code pytest test_a5_confirm_schema.py
BE-2 Inline 新建 device 在 @transaction 內寫 public.devices → 寫 SSP item 帶 device_id FK pytest test_a5_inline_create_device.py(mock + real domain service)
BE-3 Inline 新建 info_system 同上對 compliance.information_systems + SSP item 帶 system_characteristic_id FK pytest test_a5_inline_create_info_system.py
BE-4 Inline 新建 org_unit / user 透過 jedi-auth domain service pytest test_a5_inline_create_party.py
BE-5 Content_overrides 蓋掉 parsed_result 對應欄位(basic / controls.impl / controls.objectives)後再寫入 pytest test_a5_content_overrides.py
BE-6 Decisions skip_as_text → SSP item 寫純文字,FK=null pytest test_a5_decision_skip_as_text.py
BE-7 Inline 新建撞 duplicate / validation 錯誤 → 整段 @transaction rollback;無髒資料 pytest test_a5_inline_create_rollback.py
BE-8 DELETE /ssp-excel-import/<parse_uid> soft delete parse job,後續 GET 回 404 pytest test_a5_discard_endpoint.py
BE-9 import_summary 含 inline_created_* + decision_applied_count + skipped_as_text_count 計數 pytest 範圍同 BE-2 / BE-3
BE-10 0 regression — a2/a3/a4/a5 ssp_excel 全綠 pytest tests/test_a*_*.py tests/test_ssp_excel_import_app_service.py

6.2 FE Acceptance

# AC 驗證方式
FE-1 /module-frame/import-excel 路由 + 上傳頁開得起來 manual smoke
FE-2 9 sheet tab 全部能展示 parsed_result(基本資料 / parties / devices / info_systems / leveraged / controls / refdocs) manual smoke
FE-3 Unmatched device 點「選現有」彈 dropdown,autocomplete tenant.devices manual smoke
FE-4 Unmatched device 點「新建」彈 InlineCreateDialog,填完 confirm 後該列顯示淡藍 badge manual smoke
FE-5 Fuzzy match 顯示 confidence + override 按鈕;override 可改 4 種 action manual smoke
FE-6 編輯欄位 / 切 tab / 重整頁面 → state 由 localStorage 恢復 manual smoke
FE-7 確認匯入 modal 顯示總計 + 點 "確定" 觸發 POST confirm manual smoke
FE-8 Confirm 成功 → 清 localStorage + redirect MF 詳細頁 + success toast manual smoke
FE-9 i18n zh-tw + en manual smoke 切語言
FE-10 上傳錯誤 / parse 過期 / confirm 失敗 各種錯誤路徑顯示對應錯誤訊息 manual smoke

7. 風險與緩解

風險 緩解
Inline 新建 user / org_unit 撞 jedi-auth domain service 簽章變動 開工前 T0 verify:UserDomainService.add / OrgUnitDomainService.add 簽章;落地偏差進 §11
Confirm @transaction 過長(5 entity write + N inline create + content_overrides)超時 假設 N ≤ 10(單次匯入小);若 prod 大量 inline create 再評估批次
localStorage 容量限制(parsed_result 大型 SSP 過大) 預估 ~500KB;瀏覽器 5-10MB 限制夠用。若超量再評估 IndexedDB
FE inline 新建 dialog form fields 跟 tenant 表 schema 不一致 T0 verify:4 種 entity 必填欄位列在 design § 4.1 InlineCreateSchema.payload 子欄位附註
User confirm 後發現錯誤想 undo 沒做 — confirm 是 final commit;user 要 undo 走另外的 MF / SSP 編輯路徑(非本 phase 範圍)

8. 跨 repo 工作

Repo 範圍 branch
compliance-manager-be BE confirm schema + inline_creates handling + DELETE endpoint + pytest feature/ssp-import-export-phase2(沿用)
compliance-manager-fe /module-frame/import-excel 路由 + 11 個 Vue 元件 + Pinia store + service + i18n 新建 feature/ssp-import-export-phase2 對齊 BE 命名
compliance-manager-test cucumber 拍板跳過(F-A5-cucumber follow-up)
jedi-* 不動

9. Pre-flight Verification(T0 必驗)

開工前 T0 verify 以下假設,落地偏差進 §11:

  1. 既有 SspExcelImportConfirmRequestSchema.decisions / overrides 彈性 dict → 改成結構化 schema 後 A2 既有 test 是否 break
  2. jedi-auth UserDomainService.add / OrgUnitDomainService.add 簽章 → 確認可在 BE 主專案 @transaction 內 invoke
  3. DeviceDomainService.add / InformationSystemDomainService.add 簽章 → 同上
  4. 既有 _dict_to_parsed_* helper 是否易於套 content_overrides(A4 ship 已寫,T8 內)
  5. FE 既有 BaseService extension pattern(如 MfTemplateService.js
  6. PrimeVue Dialog / Dropdown / Autocomplete 既有用法 reference

10. Implementation Reality / Reconciliation

10.1 T0 Pre-flight verify 偏差(Session B 開工前 — 2026-05-20)

10.1.1 — Domain service .add() 方法名 3/4 不一致(plan §0.2 假設破裂)

Plan 假設 4 個 inline create domain service 都用 .add(entity) 統一簽章。實際:

Domain Service Plan 假設 實際 method
DeviceDomainService .add(entity, user_context) .add_device(entity)
InformationSystemDomainService .add(entity, user_context) .add(entity)
OrgUnitDomainService (jedi-auth) .add(entity, user_context) .add_org_unit(entity)
UserDomainService (jedi-auth) .add(entity, send_invite_flag) .add_user(entity, is_admin=False)

對應_apply_inline_creates 改成 entity_type → method-name dispatch(不能用統一 .add)。Entity 內 created_user / updated_user / tenant_id 在 dispatch 前 populate(method 簽章不收 user_context)。

10.1.2 — UserDomainService.add_usersend_invite 機制

Plan 設計 inline-create user 走 + send_invite=True 自動寄邀請信。實際 add_user(entity, is_admin=False) 只 hash password + insert,不發信。

對應:A5 phase 範圍不發邀請信。Inline 新建的 user 帳號由 admin 後續手動處理(最簡 path;user 拍板 2026-05-20)。若要發信走 follow-up(建議名稱:F-A5-user-invite-on-inline-create)。

10.1.3 — DELETE endpoint + discard_parse 已 ship(A2 階段)

Plan T5 假設要新加 DELETE route + app service discard_parse method。實際 A2 phase 已 ship 兩者:

  • Route:api/oscal/routes/ssp/ssp_excel_import_route.py:85-93 (SspExcelImportRoute.delete())
  • App service:app/oscal/service/ssp_excel_import_app_service.py:256-264 (discard_parse)
  • Schema:SspExcelImportDiscardResponseSchema 已存在

對應:T5 縮為「補 ~3 個 discard pytest」(併入 T7 範圍處理)。T5 commit 取消。

10.1.4 — content_overrides 命名沿 docx pattern(implementation + objectives[<key>]

Design §4.1 原寫 controls["AC-1"]["implementation_description"]。但 parsed_result 實際用 controls_with_aos[i]["statement"] 同欄位給 control + AO。為避免 mapping 層,content_overrides shape 沿 docx pattern(已有先例,見 api/oscal/serializers/ssp/ssp_docx_import.py:55):

{
  "metadata": {"target_mf_name": "<new>", "target_system_name": "<new>", ...},
  "controls": {
    "AC-1": {
      "implementation": "<new control 層 statement>",
      "objectives": {"(a)": "<new>", "(b)": "<new>"}
    }
  }
}

_apply_content_overridesimplementation 蓋到 parsed_result 對應父 control row 的 statement 欄位;objectives[<key>] 蓋到對應 AO row 的 statement 欄位。

10.1.5 — Error code 起始序號

T0.5 verify 結果:

  • 400 last=070 → A5 新增從 GRC_400071 開始
  • 409 last=030 → A5 新增從 GRC_409031 開始
  • 412 last=026 → A5 新增從 GRC_412027 開始

10.1.6 — Tests 跳過 schema 驗證

T0.1 verify:既有 a2/a3/a4 confirm test 全部直接 svc.confirm_import("u", {}, user_context){"decisions": [], "overrides": {}} — 跳過 marshmallow schema layer。

對應:A5 app service confirm_import 內讀 payload 必用 .get("decisions") or [] / .get("inline_creates") or [] / .get("content_overrides") or {} defensive 模式 — 確保既有 tests 不破壞,schema 結構化只在 route layer 生效。

10.2 BE 實作偏差

10.2.1 — Decisions 對 write-time FK 不直接 propagate(已知限制)

問題:A5 _apply_decisions 蓋掉 parsed_result[<sheet>][row_idx]["matched_*_id"] (給 FE preview 一致性),但 _write_all_data 8-step pipeline 內 Step 3 _dict_to_parsed_* 不讀回 matched_*_id,Step 4 orchestrator reconciler 重新算 ParsedDevice 等 typed entity 的 matched_*_id,Step 7 再 by-key 對齊寫回 dict — 結果 A5 user decision 被 orchestrator 結果覆寫。

影響

  • inline_creates 正確寫入 4 種 tenant entity 並於 transaction rollback 時清除(atomicity 保證)
  • import_summary A5 stats 計數正確(inline_created_*, decision_applied_count, skipped_as_text_count)
  • parsed_result mutation 套用後可由 FE 重讀 GET /ssp-excel-import/<uid> 看到
  • ⚠ 但實際 DB 寫入的 SSP item FK 仍由 orchestrator 決定 — use_existing 改 ID / use_inline_new 對應到 new_id 都不會反映在 control_implementations / ssp_system_implementation_items 的 FK 上(除非剛好 orchestrator 也選同一個)

為什麼 Session B 不修

  • 修正需動 A4 domain/oscal/service/reconciliation/base.py_already_resolved_by_decision() hook(讓 reconciler 跳過已有 user decision 的 row),跨 A4 / A5 邊界
  • A5 design §2.3 明確排除「不改 A4 ship 的 reconciler / WriteStrategy / pipeline 行為」
  • Session C FE 完成後可確認真實 user-facing 影響再決定是否升級為 follow-up bug fix

Follow-upF-A5-decision-write-propagation

  • _reconcile_one() 開頭加 if self._already_resolved_by_decision(parsed): return
  • _dict_to_parsed_* helper 把 A5 decision 標記寫到 ParsedDevice / ParsedInfoSystem / etc.(例如 _user_decision_locked=True 屬性)
  • Session C FE smoke 完後評估 priority

10.3 FE 實作偏差

10.3.1 — FE 檔案結構跟 design §3.2 不一致(FE 既有慣例 override)

design §3.2 列:

  • src/service/oscal/SspExcelImportService.js
  • src/store/modules/sspExcelImport.js
  • src/router/routes.js
  • src/locales/{zh-tw,en}/module-frame.js
  • src/views/module-frame/ImportExcel.vue

實際 FE 慣例:

  • src/service/SspExcelImportService.js(service 平鋪,無 oscal/ 子目錄;mirror SspDocxImportService.js)
  • src/stores/sspExcelImportStore.js(複數 stores/ + Store 後綴;mirror sspDocxImportStore.js)
  • src/config/router/index.js(routes 集中於 config/)
  • src/config/locales/i18n/{zh-tw,en}/ssp-excel-import.json(JSON 格式 + 各 feature 一檔;spread 到 lang.*)
  • src/views/module_frame/ImportExcelPage.vue(underscore module_frame 不是 hyphen)

對應:全部 FE 改動依 FE 既有 docx import pattern 對齊 — 路徑跟 design 不一致是 design 寫得太早未驗 FE repo。

10.3.2 — 5 sheet 而非 9 sheet(BE schema 限制)

design §3.2 plan 7 個 sheet preview Vue component(covering 00_說明 + 01-08 共 9 sheet)。實際:

  • BE DecisionSchema.sheet OneOf 只 5 個:devices / info_systems / leveraged / parties / controls
  • 其他 4 sheet(00_說明 / 01_基本資料 / 02_程序書 / 控制項 AO 文字)走 content_overrides,不需 per-row DataTable

對應:T11 ship 5 個 decision sheet preview(Devices / InfoSystems / Leveraged / Parties / Controls)+ MatchStatusCell 共用元件。SheetPreviewBasic + SheetPreviewRefDocs 留 follow-up F-A5-content-overrides-ui(T14 polish 範圍)。

10.3.3 — Upload + Preview 拆 2 個 route(跟 design §5.1 / docx pattern 不同)

design §5.1 step 2 預期 /module-frame/import-excel/<parse_uid> 路由直接 redirect 到同 page;docx 既有 ImportDocxPage 是「單 page 同時做 upload + preview」。

實際:

  • /module-frame/import-excel (create) → ImportExcelPage.vue(純上傳)
  • /module-frame/:uid/import-excel (update) → 同上
  • /module-frame/import-excel/preview/:parseUid → ImportExcelPreviewPage.vue(純預覽)

對應:拆 page 換得 page-reload 跨 navigation 恢復 + share link(user 把 preview URL 給同事接手匯入)。Trade-off:增加 1 個 route + 1 個 view file,但 store / service / i18n 全共用。

10.3.4 — RowActionMenu / 不寫 FuzzyBadge 元件

design §3.2 plan 列 UnmatchedRow + InlineCreateDialog + FuzzyBadge 三個共用元件。實際:

  • RowActionMenu.vue — 替代 UnmatchedRow,用 PrimeVue Menu popup 顯示 4 action(pick_existing / inline_new / skip_as_text / reset)
  • InlineCreateDialog.vue — 4 entity form(device / information_system / org_unit / user)
  • EntityPickerDialog.vue — 新增(從 menuStore 拿對應 menu + PrimeVue Dropdown filter)
  • FuzzyBadge.vue — 不另寫;fuzzy 標示 + confidence % 整合進 MatchStatusCell.vue(T11 ship)

對應:fuzzy / unmatched / matched 都在同個 status cell 一致顯示,user override 走 RowActionMenu。少寫一個 component,職責更清楚。

10.3.5 — Leveraged sheet RowActionMenu 不接(BE 不支援)

BE InlineCreateSchema.entity_type OneOf:device / information_system / org_unit / user不含 leveraged party。leveraged 的 matched_party_uuid 對應 OSCAL Party (org / person),但無對應 party menu API 給 FE 用 picker。

對應

  • T12 RowActionMenu 未接 leveraged sheet,action column 為 placeholder
  • leveraged 仍能透過 BE A3/A4 reconciler 自動配對(不影響 happy path)
  • Follow-up F-A5-leveraged-action:T14 polish or 後續評估是否值得補 party menu API + inline-create leveraged 流程

10.3.6 — localStorage shape 跟 §4.3 example 不一致

§4.3 示例:

{ decisions: [...flat array...], inline_creates: [...flat array...] }

實際 store 持久化 shape:

{
  version: 1,
  saved_at: "<iso>",
  decisions: { devices: { 0: { action, matched_id_override, inline_create_local_id } }, ... },
  inlineCreates: { ic_xxx_1: { sheet, row_idx, entity_type, payload }, ... },
  contentOverrides: { metadata: {...}, controls: {...} },
  filters: { devices: 'all', ... }
}

差別

  • decisionssheet → rowIdx nested dict 而非 flat array — DataTable per-row lookup O(1)
  • inlineCreates 用 Map 而非 array — 避免 user 取消某筆 inline create 後 splice 引發其他 decisions 的 inline_create_row_idx shift
  • 加了 contentOverrides + filters 進持久化(reload 後保留編輯狀態 + 已選 filter)
  • buildConfirmPayload() 才把 Map 重新 enumerate 成 BE schema array 並 remap localId → array index

對應:localStorage 是 FE 私有格式(BE 不 touch),internal shape 自由設計。Trade-off 已寫在 src/stores/sspExcelImportStore.js 開頭 comment("Why a Map for inlineCreates")。

10.3.7 — T14 manual smoke 拋給 user 跑(agent 無 GUI)

plan §2.T14 寫「manual smoke:上傳 → 預覽 → 編輯 → unmatched 三路徑 → fuzzy override → confirm → 驗 DB」隱含 agent 跑完整流程。實際 agent 無 browser interactive 能力。

對應

  • T14 agent 完成的是 npm run build:DEV build verify(compile / lint / import 解析全過 — ImportExcelPreviewPage 30.32 kB chunk ship 進 dist)
  • Manual smoke 寫成 8 段 checklist 給 user 自跑:docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/2026-05-21-a5-smoke-checklist.md
  • T15 收口完成;user 跑 smoke 撞到 bug → 走 docs/issues/ 流程(2026-05-21-a5-smoke-to-bugfix.md handoff prompt 已備)

10.3.8 — T11 從 7 個獨立 component 變 5 sheet + 1 共用 cell

plan §2.T11 列「7 個 sheet preview 元件」(含 SheetPreviewBasic / Parties / Devices / InfoSystems / Leveraged / Controls / RefDocs)。實際 ship:

  • 5 個 decision sheet preview (Devices / InfoSystems / Leveraged / Parties / Controls)
  • 1 個共用 MatchStatusCell.vue(matched / fuzzy / unmatched / inline_new / override / skip 共 6 種狀態)
  • 0 個 Basic + RefDocs preview(content_overrides 路徑,留 F-A5-content-overrides-ui follow-up)

對應

  • 共用 MatchStatusCell 替代 plan 隱含的 per-sheet status 重複碼
  • Basic + RefDocs 跟 §10.3.2 (5 sheet 而非 9 sheet) 一致 — 走 content_overrides 不需要 DataTable
  • 實際 component 數 = 5 sheet preview + 3 共用元件(MatchStatusCell / RowActionMenu / 2 dialogs)= 9 個 .vue 檔(比 plan 7 個多 — 因為拆共用元件)

10.4 跨 session 偏差總覽

§ 偏差類 嚴重度
10.1.1-10.1.6 T0 pre-flight verify 6 條(domain service signature / DELETE 已 ship / content_overrides 命名 / error code 序號 / test 跳 schema) medium — 不阻塞、改寫 helper 即可
10.2.1 Decisions 對 write-time FK 不直接 propagate;F-A5-decision-write-propagation 待 smoke 評估 medium-high — user 改 decision 可能不反映到 DB
10.3.1 FE 檔案結構照 FE 慣例改 low — 對齊 docx pattern
10.3.2 5 sheet 而非 9 sheet low — BE schema 本來就 5 個
10.3.3 Upload + Preview 拆 2 route low — UX 偏好
10.3.4 共用元件命名 + 不寫 FuzzyBadge low — 職責整合
10.3.5 Leveraged action column 空 medium — UX 缺洞,留 follow-up
10.3.6 localStorage shape 跟 §4.3 example 不一致(dict + Map 而非 flat array) low — FE 私有格式;trade-off 在 store comment
10.3.7 T14 manual smoke 拋給 user 跑(agent 無 GUI) low — checklist 已備
10.3.8 T11 從 7 個獨立 sheet preview 變 5 sheet + MatchStatusCell 共用元件 low — 拆共用元件比每 sheet 重寫好維護

11. 執行階段切分(建議 3 session)

Session 範圍 Task 預估 Commit checkpoint
A(當前) Design + Plan design-A5.md + implementation-plan-A5.md + T0 verify 0.5d docs(ssp-import-export-phase2): A5 design + plan
B BE 實作 T1 schema 定稿 + T2 inline_creates handling + T3 DELETE endpoint + T4 content_overrides + T5 pytest 1.5d feat(oscal): A5 BE confirm schema + inline_creates
C FE 實作 + 收尾 T6 路由 + Vue 元件 + Pinia store + i18n + manual smoke + SUMMARY 2d feat(fe): A5 import-excel preview page + docs(ssp-import-export-phase2): A5 收尾

總計:3 session、~4d(含 design / BE / FE / 收尾)

注意:4 天略超 spec sizing rule 4 (3-day cap)。理由:A5 BE + FE 不能拆兩個 spec(拆掉就破壞「ship 後 user 能用」規則 #1)。Mitigation:partial ship pattern — BE 先 ship 後可獨立 smoke test(FE 還沒做時可用 curl 驗證);FE ship 後才是完整 user-facing 完成。

11.1 換 session 收尾規範

按 CLAUDE.md「做 summary 觸發完整收尾」段:

  1. 盤點 commits + working tree 乾淨
  2. 規範文件齊全度檢查(changelog / analysis / issue / design §10 reconciliation)
  3. 產 handoff prompt → docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/2026-05-XX-a5-<session 字母>-next.md
  4. Session C 是 task arc 收口 → 對話歷史 dump 到 docs/conversation-history/<date>/ssp-import-export-phase2-A5/

12. 設計決策溯源(user 拍板)

12.1 主要拍板(2026-05-20,A4 ship 後)

# 議題 拍板 理由
Q1 Inline 新建時機 Confirm 階段 BE 一次處理 原子性最強;user 取消預覽不留髒資料
Q2 Fuzzy match UI 行為 預設 auto-accept + fuzzy badge,允許 user override 沿 A3/A4 既有行為;balance UX 流暢度 vs 準確度
Q3 FE 路由結構 新建 /module-frame/import-excel 獨立頁面 docx 跟 Excel 資料結構差異大(iframe PDF vs 9 sheet 表格),共用元件度低;獨立路由 maintenance 更清楚

12.2 預設拍板(沿 A1-A4 慣例,不額外問)

  • DDD 嚴格分層(Route 不查 DB / app service @transaction / domain service 走 add())
  • Error code 命名沿 GRC_<status><3 位序號> 規則
  • 跨 repo branch 名稱對齊 feature/ssp-import-export-phase2
  • jedi-* 不動,feature 全部完工才 bump version
  • Cucumber 拍板跳過,留 F-A5-cucumber follow-up
  • localStorage 暫存策略沿 docx import 既有 pattern