Phase:A2 級別:中型 依賴:A1 已 ship(樣板下載 + TEMPLATE_VERSION v1.1.0) 預計工時:BE 2.75d + test 0.5d ≈ 3.5 working day(FE 不動,A5 才補預覽 UI) 對應 design:
docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A2.md
| Task | 主題 | repo | 預估 | 依賴 |
|---|---|---|---|---|
| 0 | Pre-flight verification(5 個假設複驗 + 既有 RLS policy / mirror 結構查證) | BE | 0.25d | — |
| 1a | A1 樣板 v1.2.0 minor bump — 07 sheet 加 include_in_profile 欄位(A2 prerequisite) |
BE | 0.25d | T0 |
| 1 | DB migration — 新建 oscal.ssp_excel_parse_jobs table + 權限 + RLS |
BE | 0.25d | T0 |
| 2 | Domain + Infra 層 — entity / model / repo / mapper / domain service | BE | 0.5d | T1 |
| 3 | Excel Parser 核心 — app/oscal/service/excel_parser/ 純函式 + 9 sheet 解析 + validation |
BE | 1d | T0, T1a |
| 4 | App Service skeleton — SspExcelImportAppService 4 個 public method(upload / get / discard / confirm) |
BE | 0.5d | T2, T3 |
| 5 | Route + Serializer — 4 個 Resource + marshmallow schema | BE | 0.25d | T4 |
| 6 | DI wiring + Error code 註冊 | BE | 0.25d | T5 |
| 7 | Unit test — parser / app service mock 路徑 ≥ 50 case | BE | 0.5d | T3, T4 |
| 8 | BE smoke + changelog ×2 + tracker | BE | 0.25d | T7 |
Task 1, 2 必須序列;Task 3 可跟 T1/T2 並行(純函式不依賴 DB),但 T3 要 T1a 完成(parser 對齊 v1.2.0 column index)。 Task 5 (Route) 必須在 T4 完成後(介面要對齊 app service signature)。 並行最佳化:T1 ∥ T1a;T2 完成後 ∥ T3。 T1a 不動 DB,是 A1 樣板 generator code patch + TEMPLATE_VERSION bump — 可在 T0 結束後立刻並 T1 起跑。
design-A2 §9 已列 6 項已驗事實;T0 補剩餘 5 個 design 未確認假設,動 code 前一次釐清。
ssp_docx_parse_jobs RLS policy 結構# 看 docx parse_jobs migration 內 RLS policy 寫法
grep -n "POLICY\|ALTER TABLE.*ssp_docx_parse_jobs\|ROW LEVEL" \
scripts/sql/2026-05-01-ssp-docx-parser-consolidated.sql \
scripts/sql/ssp_docx_parse_jobs_migration.sql 2>/dev/null期待結果:找到既有 policy SQL(如 tenant_id = ANY(current_setting('app.allowed_tenant_paths')::int[]))。A2 直接 copy 同樣 policy 套到 ssp_excel_parse_jobs。
Impact:design-A2 §5 RLS 段定稿。
ProfileService.add_profile data payload schemagrep -n "def add_profile\|catalog_uid\|include_controls\|metadata" \
~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/app/services/profile/profile_service.py \
| head -30期待結果:確認 data dict 接受哪些 key(title / description / status / catalog_uid / include_controls / ...),決定 A2 confirm 階段組 payload 的方式。
Impact:Task 4 confirm_import 寫 profile 創建邏輯時不踩雷。
ModuleFrameService.add_module_frame payload schemased -n '120,190p' app/module_frame/service/module_frame_service.py期待結果:確認 add_module_frame 接受 oscal_profile_uid 直接傳,還是要自己組 profile 後再回填 MF。
Impact:Task 4 superset flow 順序(先建 profile → 拿到 profile_uid → 建 MF?還是反過來)。
file_upload_service 兩軌 storage helpersed -n '155,200p' app/oscal/service/ssp_docx_import_app_service.py期待結果:確認 mirror pattern:tempfile for parser + FileStorage wrap for upload_service。A2 直接 copy 相同段 code。
Impact:Task 4 upload_and_parse 段 storage 處理直接照搬。
party_reconciliation_service 是否已 wire 可直接用grep -rn "party_reconciliation_service\|PartyReconciliation" di_containers/ 2>/dev/null | head -10
grep -n "def reconcile" domain/oscal/ app/oscal/ 2>/dev/null | head -10期待結果:確認 reconciliation service 已存在且 DI 已 wired(docx import 已用)。A2 confirm 階段可直接 inject。
Impact:Task 6 DI;確認需不需要新建 reconciliation 入口或沿用既有。
design-A2 §13.1 引入:07 sheet 加
include_in_profilecheckbox 欄位;A1 generator code patch;TEMPLATE_VERSION bump v1.1.0 → v1.2.0。
# openpyxl 內 Data Validation list 跟 Form Control checkbox 哪個跨 Excel 版本穩定
poetry run python3 -c "
from openpyxl import Workbook
from openpyxl.worksheet.datavalidation import DataValidation
wb = Workbook()
ws = wb.active
dv = DataValidation(type='list', formula1='\"TRUE,FALSE\"', allow_blank=True)
ws.add_data_validation(dv)
dv.add('B2:B10')
ws['B2'] = True
wb.save('/tmp/checkbox_test.xlsx')
print('TRUE/FALSE dropdown OK')
"期待結果:用 DataValidation type=list formula1='\"TRUE,FALSE\"' 即可(openpyxl 不直接支援 Form Control checkbox;Data Validation 下拉 TRUE/FALSE 在 Excel / Numbers 都顯示 dropdown,UX 接近 checkbox)。
決策:若 verify 結果不滿意(如 Numbers 顯示異常)→ 改用「直接 cell 顯示 TRUE/FALSE 純文字 + DataValidation」;A2 parser 端對 TRUE/True/true/1/Y/是 都接受。
# app/module_frame/excel_template/sheet_definitions.py
# SHEET_CONTROLS columns 在 control_id 後加:
SHEET_CONTROLS = SheetDef(
sheet_name="07_控制項與AO",
columns=(
ColumnDef("statement_id", ..., hidden=True), # A 欄
ColumnDef("include_in_profile", "field.include_in_profile",
required=False, enum_values=("TRUE", "FALSE")), # B 欄(A2 新增)
ColumnDef("control_id", ..., required=True), # C 欄(原 B → C)
ColumnDef("control_name", ...), # D 欄
ColumnDef("objective_id", ...),
ColumnDef("objective_name", ...),
ColumnDef("impl_status", ..., enum_values=(...), required=True),
ColumnDef("statement", ..., required=True),
ColumnDef("reference_doc", ...),
),
)| Generator 路徑 | include_in_profile 預填 |
|---|---|
| MF-scoped blank / filled | 父 row 全部 TRUE |
| FW-version-scoped blank(superset) | 父 row 全部 FALSE(user 訪談時逐筆勾選) |
子 row(AO)此欄留空(openpyxl None)— checkbox 只在父 row 有意義。
# app/module_frame/excel_template/sheet_definitions.py(或 generator 主檔)
TEMPLATE_VERSION = "v1.2.0" # 原 v1.1.0既有 A1 test 內若用 ws['C2'] 直接讀 control_id 的,column letter 都要 +1(B→C, C→D 等)。
grep -rn "ws\\['[A-Z]2'\\]\\|cell(row=2,\\s*column=[0-9]" \
tests/test_excel_template_*.py | head -20逐筆同步更新。
config/translations/zh_Hant_TW/LC_MESSAGES/messages.po + en 加 field.include_in_profile 對應:
| 檔案 | 動作 |
|---|---|
scripts/sql/2026-05-XX-ssp-excel-parse-jobs.sql |
新建 — 對齊 design-A2 §5;含 Date 註解 + GRANT + RLS policy(T0.1 結果) |
對齊 design-A2 §5 的 5 段 SQL:建 table / 索引 ×2 / GRANT / ENABLE RLS + CREATE POLICY。
domain/oscal/entity/ssp_excel_parse_job_entity.py
domain/oscal/repository/i_ssp_excel_parse_job_repo.py
domain/oscal/service/ssp_excel_parse_job_domain_service.py
infra/oscal/model/ssp_excel_parse_job.py
infra/oscal/mapper/ssp_excel_parse_job_mapper.py
infra/oscal/repository/ssp_excel_parse_job_repo_impl.py
SspExcelParseJobEntity 跟 model 同欄位 + uidto_entity / to_modelBaseRepositoryImpl[Entity, QueryEntity, Model, Mapper],session 自動有create(source_type, source_uid, template_version, metadata, file_name, file_size, tenant_id, user) -> Entityupdate_status(uid, status, user, file_path=None) -> Nonewrite_parsed_result(uid, parsed_result_dict, user) -> Nonewrite_error(uid, error_code, error_message, user) -> Nonewrite_import_summary(uid, summary, user) -> Noneget_one(uid) -> Entity | Nonediscard(uid, user) -> None(軟刪 is_active=False)app/oscal/service/excel_parser/
├── __init__.py
├── parser.py # ExcelParser 主類 + parse() 入口
├── sheet_handlers.py # 9 個 sheet 各自 handler(parse_metadata / parse_parties_org / ...)
├── validators.py # role enum / required / unknown enum value 驗證
├── version_check.py # _check_template_version + SUPPORTED_TEMPLATE_VERSION_RANGE
└── types.py # ParsedExcel / ParsedSection / ValidationError dataclass
@dataclass
class ValidationError:
sheet: str
row: int
field: str
code: str
message: str
blocking: bool = False
@dataclass
class ParsedExcel:
template_version: str
metadata: dict | None # 01 sheet
parties_org: list[dict] # 02 sheet
parties_person: list[dict] # 03 sheet
devices: list[dict] # 04 sheet
info_systems: list[dict] # 05 sheet
leveraged: list[dict] # 06 sheet
controls_with_aos: list[dict] # 07 sheet(含 objectives 巢狀)
ref_docs: list[dict] # 08 sheet
validation_errors: list[ValidationError]
def to_dict(self) -> dict:
"""寫入 parse_job.parsed_result (JSONB) 用"""class ExcelParser:
def parse(self, file_path: str, source_type: str, source_uid: str) -> ParsedExcel:
wb = load_workbook(file_path, data_only=True)
template_version = self._read_template_version(wb)
self._check_template_version(template_version)
validation_errors: list[ValidationError] = []
result = ParsedExcel(
template_version=template_version,
metadata=self._parse_metadata_sheet(wb, validation_errors),
parties_org=self._parse_parties_org_sheet(wb, validation_errors),
parties_person=self._parse_parties_person_sheet(wb, validation_errors),
devices=self._parse_devices_sheet(wb, validation_errors),
info_systems=self._parse_info_systems_sheet(wb, validation_errors),
leveraged=self._parse_leveraged_sheet(wb, validation_errors),
controls_with_aos=self._parse_controls_sheet(wb, validation_errors),
ref_docs=self._parse_ref_docs_sheet(wb, validation_errors),
validation_errors=validation_errors,
)
return result照 design-A2 範例 code 實作;對齊 A1 generator 的「父 row statement_id 空 / 子 row statement_id=AO uid」contract。
VALID_PARTICIPANT_ROLES = {"manager", "reviewer", "auditor", "viewer"}
def validate_person_role(value: str, sheet: str, row: int) -> tuple[str, ValidationError | None]:
role = (value or "").strip().lower()
if not role:
return "", None
if role not in VALID_PARTICIPANT_ROLES:
return role, ValidationError(
sheet=sheet, row=row, field="role", code="INVALID_ROLE",
message=f"role '{role}' 不在 manager/reviewer/auditor/viewer 範圍",
blocking=True,
)
return role, None對齊 design-A2 §7 issue 修補 A。
app/oscal/service/ssp_excel_import_app_service.py
_EXCEL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
_PARSE_JOB_TTL_HOURS = 24
class SspExcelImportAppService:
def __init__(self, parse_job_domain_service, parser, file_upload_service,
module_frame_domain_service, profile_service, module_frame_service,
party_domain_service, responsible_party_domain_service,
oscal_framework_version_domain_service, catalog_control_domain_service,
party_reconciliation_service=None, ...):
self._parse_job = parse_job_domain_service
self._parser = parser
# ... 其他 dep
@transaction
def upload_and_parse(self, file, source_type, source_uid, metadata, user_context) -> dict:
# 1. file validation(副檔名 / size)
# 2. source 存在性 + 權限檢查
# 3. 建 parse_job(status=pending)
# 4. 走兩軌 storage:tempfile + file_upload_service
# 5. self._parser.parse(tmp_path, source_type, source_uid) → ParsedExcel
# 6. write_parsed_result(parsed.to_dict()) + status=awaiting_review
# 7. unlink tempfile
# 8. return {parse_uid, status, file_uid, summary}
@transaction
def get_parse_result(self, parse_uid, user_context) -> dict:
# 1. get_one + tenant check + ttl check + is_active
# 2. return job.to_dict() including parsed_result
@transaction
def discard_parse(self, parse_uid, user_context) -> dict:
# 1. permission check
# 2. domain_service.discard
# 3. return {parse_uid, status: 'discarded'}
@transaction
def confirm_import(self, parse_uid, payload, user_context) -> dict:
# 1. get + status / ttl / blocking-validation 守門
# 2. dispatch by source_type:
# - 'framework_version' → _confirm_superset_flow
# - 'module_frame' → _confirm_update_flow
# 3. write_import_summary + status=completed
def _confirm_superset_flow(self, job, parsed_result, decisions, overrides, user_context):
# 1. 取 framework_version + catalog
# 2. 從 controls_with_aos 篩納入 profile 的 control_ids → catalog_control_uid list
# 3. ProfileService.add_profile({catalog_uid, include_controls, title, ...})
# 4. ModuleFrameService.add_module_frame({oscal_profile_uid=新, oscal_framework_version_uid, name, ...})
# 5. _write_parties / _write_items / _write_control_defaults / _write_ref_docs (context_type='module_frame', context_id=新 MF.id)
# 6. return import_summary
def _confirm_update_flow(self, job, parsed_result, decisions, overrides, user_context):
# 1. 取既有 MF(已驗存在)
# 2. 不動 profile
# 3. _write_parties / items / control_defaults / ref_docs upsert
# 4. return import_summary
# _write_* helper:A2 直接寫,不靠 decisions 拆 append/update/skip(A5 補完)api/oscal/serializers/ssp/ssp_excel_import.py
api/oscal/routes/ssp/ssp_excel_import_route.py
class SspExcelImportParseRequestSchema(Schema):
"""POST /ssp-excel-imports/parse — form fields"""
source_type = fields.Str(required=True, validate=validate.OneOf(['framework_version', 'module_frame']))
source_uid = fields.Str(required=True)
metadata = fields.Dict(required=False) # JSON-encoded form field
class SspExcelImportParseResponseSchema(Schema):
"""POST /parse response"""
parse_uid = fields.Str()
status = fields.Str()
file_uid = fields.Str()
summary = fields.Dict(allow_none=True)
error_code = fields.Str(allow_none=True)
error_message = fields.Str(allow_none=True)
class SspExcelImportPreviewResponseSchema(Schema):
"""GET /<uid>"""
uid = fields.Str()
source_type = fields.Str()
source_uid = fields.Str()
status = fields.Str()
file_uid = fields.Str()
file_name = fields.Str()
parsed_result = fields.Dict()
created_at = fields.DateTime()
ttl_expires_at = fields.DateTime()
class SspExcelImportConfirmRequestSchema(Schema):
"""POST /<uid>/confirm"""
decisions = fields.List(fields.Dict(), required=False)
overrides = fields.Dict(required=False)
class SspExcelImportConfirmResponseSchema(Schema):
parse_uid = fields.Str()
status = fields.Str()
module_frame_uid = fields.Str()
profile_uid = fields.Str(allow_none=True)
import_summary = fields.Dict()4 個 Resource:SspExcelImportParseRoute / SspExcelImportRoute (GET+DELETE) / SspExcelImportConfirmRoute。 URL prefix /api/1.0,註冊到 api/oscal/__init__.py 既有 blueprint。
di_containers/oscal/oscal_containers.py:
ssp_excel_parse_job_repo (Singleton)ssp_excel_parse_job_domain_service (Factory)excel_parser (Singleton — 純函式 class)ssp_excel_import_app_service (Factory,inject 上述 + 既有 domain services)di_containers/containers.py:sub-container 已在主 container 內,wiring 自動掃描即可。
common/code/grc_error_code.py 加(序號從 design-A2 §9 已驗:400065+ / 404033+ / 412013+):
GRC_EXCEL_INVALID_FILE = ("Excel 檔格式無效", "GRC_400065")
GRC_EXCEL_FILE_TOO_LARGE = ("Excel 檔超過大小限制(10MB)", "GRC_400066")
GRC_EXCEL_SOURCE_TYPE_INVALID = ("Excel 匯入 source_type 無效(需 framework_version 或 module_frame)", "GRC_400067")
GRC_EXCEL_SOURCE_UID_REQUIRED = ("Excel 匯入 source_uid 必填", "GRC_400068")
GRC_EXCEL_TEMPLATE_VERSION_UNSUPPORTED = ("Excel 樣板版本不支援,請重新下載最新樣板", "GRC_400069")
GRC_EXCEL_VALIDATION_ERRORS_BLOCKING = ("Excel 內容含阻斷級錯誤,請修正後重新上傳", "GRC_400070")
GRC_EXCEL_PARSE_JOB_NOT_FOUND = ("Excel 解析任務不存在", "GRC_404033")
GRC_EXCEL_PARSE_JOB_NOT_AWAITING = ("Excel 解析任務狀態不是 awaiting_review,無法確認匯入", "GRC_412013")
GRC_EXCEL_PARSE_JOB_TTL_EXPIRED = ("Excel 解析任務已過期(24h)", "GRC_412014")tests/test_ssp_excel_parser.py # parser 純函式 ≥ 25 case
tests/test_ssp_excel_import_app_service.py # app service mock domain ≥ 20 case
tests/test_ssp_excel_parse_job_mapper.py # mapper round-trip ≥ 5 case
include_in_profile v1.2.0 樣板:TRUE / FALSE / true / "Y" / "是" / 空 → 對應 _target_in_profile 值include_in_profile v1.0/v1.1 樣板 fallback:所有父 row _target_in_profile=True(向後相容)include_controls 只含 _target_in_profile=True 的 control_id_target_in_profile,既有 MF profile 不動,parties/items upsert 被呼叫create / get_one / update_status 走得通(fixture transaction scope)TOKEN=$(./scripts/dev_login.sh)
FW_VERSION_UID="..." # dev DB 既有 framework_version
MF_UID="..." # dev DB 既有 MF
# Superset flow — 拿 A1 superset 樣板填好後上傳
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "X-Tenant-ID: 102" \
-F "file=@/tmp/test_superset.xlsx" \
-F "source_type=framework_version" \
-F "source_uid=$FW_VERSION_UID" \
-F 'metadata={"target_mf_name":"A2 Smoke MF","target_group":"smoke","target_version":"0.1","target_frequency":"annual"}' \
http://localhost:8000/api/1.0/ssp-excel-imports/parse
# 拿 parse_uid → GET → confirm
PARSE_UID="..."
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8000/api/1.0/ssp-excel-import/$PARSE_UID
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"decisions":[],"overrides":{}}' \
http://localhost:8000/api/1.0/ssp-excel-import/$PARSE_UID/confirm
# MF update flow — 拿 A1 filled 樣板填好上傳
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/tmp/test_update.xlsx" \
-F "source_type=module_frame" \
-F "source_uid=$MF_UID" \
http://localhost:8000/api/1.0/ssp-excel-imports/parse
# 重複 GET + confirm驗 dev DB:
-- 確認新 MF / profile 寫入
SELECT name, oscal_profile_uid, oscal_framework_version_uid FROM compliance.module_frames WHERE name = 'A2 Smoke MF';
SELECT uid, status FROM oscal.oscal_profiles WHERE uid = '<新 profile uid>';
SELECT COUNT(*) FROM oscal.oscal_parties WHERE uid IN (...);
SELECT COUNT(*) FROM oscal.ssp_system_implementation_items WHERE scope_type='module_frame' AND scope_id=<新 MF.id>;Changelog #1:docs/changelog/YYYY-MM-DD-tweak-ssp-import-template-v1.2.0.md
type: tweak, modules: [ssp-import-template], breaking: falseinclude_in_profile checkboxChangelog #2:docs/changelog/YYYY-MM-DD-feat-ssp-excel-import.md
type: feat, modules: [ssp-excel-import, oscal], issue: docs/issues/pending/2026-05-19-person-role-cross-domain-inconsistency.mddocs/features/FR-011.2-2605-ssp-import-export-phase2/README.md:A2 row 從 pending → shipped,補 commit / changelog 連結。
下一步:等使用者 review design-A2 + plan-A2 → GO → 進 Task 0 verify。