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 端到端完整可用
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.decisions 是 List[Dict]、overrides 是 Dict(彈性,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 暫存 |
@transaction 內 atomically 寫 tenant 表 → 拿 id → 寫 SSP item with FKmatched_*_id / match_method / match_confidence;A5 FE 直接讀GET /ssp-excel-import/<parse_uid> endpoint + parsed_result 結構;A5 不改 endpoint shape,只 polish responseBE:
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 對應欄位public.devices / compliance.information_systems / org_units / users(最後 2 個透過 jedi-auth domain service)FE:
/module-frame/import-excel 路由 + Vue 元件骨架Test:
| # | 項目 | 為什麼不做 / 何時做 |
|---|---|---|
| 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 |
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 擴)。
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
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 取消預覽不留髒資料)。
confirm_import 用 @transactiondomain service.add(),不直接 import ORM modelclass 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>"}
}
}
}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
}// 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 時清掉。
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
| 情境 | 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 |
| # | 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 |
| # | 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 |
| 風險 | 緩解 |
|---|---|
| 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 範圍) |
| 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-* | 不動 | — |
開工前 T0 verify 以下假設,落地偏差進 §11:
SspExcelImportConfirmRequestSchema.decisions / overrides 彈性 dict → 改成結構化 schema 後 A2 既有 test 是否 breakUserDomainService.add / OrgUnitDomainService.add 簽章 → 確認可在 BE 主專案 @transaction 內 invokeDeviceDomainService.add / InformationSystemDomainService.add 簽章 → 同上_dict_to_parsed_* helper 是否易於套 content_overrides(A4 ship 已寫,T8 內)MfTemplateService.js).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)。
UserDomainService.add_user 無 send_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)。
discard_parse 已 ship(A2 階段)Plan T5 假設要新加 DELETE route + app service discard_parse method。實際 A2 phase 已 ship 兩者:
api/oscal/routes/ssp/ssp_excel_import_route.py:85-93 (SspExcelImportRoute.delete())app/oscal/service/ssp_excel_import_app_service.py:256-264 (discard_parse)SspExcelImportDiscardResponseSchema 已存在對應:T5 縮為「補 ~3 個 discard pytest」(併入 T7 範圍處理)。T5 commit 取消。
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_overrides 把 implementation 蓋到 parsed_result 對應父 control row 的 statement 欄位;objectives[<key>] 蓋到對應 AO row 的 statement 欄位。
T0.5 verify 結果:
GRC_400071 開始GRC_409031 開始GRC_412027 開始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 生效。
問題: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> 看到use_existing 改 ID / use_inline_new 對應到 new_id 都不會反映在 control_implementations / ssp_system_implementation_items 的 FK 上(除非剛好 orchestrator 也選同一個)為什麼 Session B 不修:
domain/oscal/service/reconciliation/base.py 加 _already_resolved_by_decision() hook(讓 reconciler 跳過已有 user decision 的 row),跨 A4 / A5 邊界Follow-up:F-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 屬性)design §3.2 列:
src/service/oscal/SspExcelImportService.jssrc/store/modules/sspExcelImport.jssrc/router/routes.jssrc/locales/{zh-tw,en}/module-frame.jssrc/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。
design §3.2 plan 7 個 sheet preview Vue component(covering 00_說明 + 01-08 共 9 sheet)。實際:
DecisionSchema.sheet OneOf 只 5 個:devices / info_systems / leveraged / parties / controlscontent_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 範圍)。
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 全共用。
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.vueMatchStatusCell.vue(T11 ship)對應:fuzzy / unmatched / matched 都在同個 status cell 一致顯示,user override 走 RowActionMenu。少寫一個 component,職責更清楚。
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。
對應:
─ placeholderF-A5-leveraged-action:T14 polish or 後續評估是否值得補 party menu API + inline-create leveraged 流程§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', ... }
}差別:
decisions 用 sheet → rowIdx nested dict 而非 flat array — DataTable per-row lookup O(1)inlineCreates 用 Mapinline_create_row_idx shiftcontentOverrides + 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")。
plan §2.T14 寫「manual smoke:上傳 → 預覽 → 編輯 → unmatched 三路徑 → fuzzy override → confirm → 驗 DB」隱含 agent 跑完整流程。實際 agent 無 browser interactive 能力。
對應:
npm run build:DEV build verify(compile / lint / import 解析全過 — ImportExcelPreviewPage 30.32 kB chunk ship 進 dist)docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/2026-05-21-a5-smoke-checklist.md2026-05-21-a5-smoke-to-bugfix.md handoff prompt 已備)plan §2.T11 列「7 個 sheet preview 元件」(含 SheetPreviewBasic / Parties / Devices / InfoSystems / Leveraged / Controls / RefDocs)。實際 ship:
MatchStatusCell.vue(matched / fuzzy / unmatched / inline_new / override / skip 共 6 種狀態)F-A5-content-overrides-ui follow-up)對應:
| § | 偏差類 | 嚴重度 |
|---|---|---|
| 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 重寫好維護 |
| 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 完成。
按 CLAUDE.md「做 summary 觸發完整收尾」段:
docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/2026-05-XX-a5-<session 字母>-next.mddocs/conversation-history/<date>/ssp-import-export-phase2-A5/| # | 議題 | 拍板 | 理由 |
|---|---|---|---|
| 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 更清楚 |
GRC_<status><3 位序號> 規則feature/ssp-import-export-phase2