Phase A2 Implementation Plan — Excel Parser + 解析 API

Phase:A2 級別:中型 依賴:A1 已 ship(樣板下載 + TEMPLATE_VERSION v1.1.0) 預計工時:BE 2.75d + test 0.5d ≈ 3.5 working day(FE 不動,A5 才補預覽 UI) 對應 designdocs/features/FR-011.2-2605-ssp-import-export-phase2/design-A2.md


§1

Task 切分概覽

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 起跑。


§2

Task 0 — Pre-flight Verification

design-A2 §9 已列 6 項已驗事實;T0 補剩餘 5 個 design 未確認假設,動 code 前一次釐清。

0.1 verify 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 段定稿。

0.2 verify ProfileService.add_profile data payload schema

grep -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 創建邏輯時不踩雷。

0.3 verify ModuleFrameService.add_module_frame payload schema

sed -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?還是反過來)。

0.4 verify file_upload_service 兩軌 storage helper

sed -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 處理直接照搬。

0.5 verify 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 入口或沿用既有。

Acceptance


§3

Task 1a — A1 樣板 v1.2.0 Minor Bump(A2 prerequisite)

design-A2 §13.1 引入:07 sheet 加 include_in_profile checkbox 欄位;A1 generator code patch;TEMPLATE_VERSION bump v1.1.0 → v1.2.0。

1a.1 verify checkbox 寫入機制

# 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/是 都接受。

1a.2 sheet_definitions.py 改動

# 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", ...),
    ),
)

1a.3 generator filled mode 預填行為

Generator 路徑 include_in_profile 預填
MF-scoped blank / filled 父 row 全部 TRUE
FW-version-scoped blank(superset) 父 row 全部 FALSE(user 訪談時逐筆勾選)

子 row(AO)此欄留空(openpyxl None)— checkbox 只在父 row 有意義。

1a.4 TEMPLATE_VERSION bump

# app/module_frame/excel_template/sheet_definitions.py(或 generator 主檔)
TEMPLATE_VERSION = "v1.2.0"   # 原 v1.1.0

1a.5 A1 unit test fixture 重排 column index

既有 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

逐筆同步更新。

1a.6 i18n key

config/translations/zh_Hant_TW/LC_MESSAGES/messages.po + enfield.include_in_profile 對應:

  • zh: 「納入 Profile」
  • en: "Include in Profile"

1a.7 Acceptance


§4

Task 1 — DB Migration

1.1 檔案改動

檔案 動作
scripts/sql/2026-05-XX-ssp-excel-parse-jobs.sql 新建 — 對齊 design-A2 §5;含 Date 註解 + GRANT + RLS policy(T0.1 結果)

1.2 SQL(依 T0.1 結果定稿)

對齊 design-A2 §5 的 5 段 SQL:建 table / 索引 ×2 / GRANT / ENABLE RLS + CREATE POLICY。

1.3 Acceptance


§5

Task 2 — Domain + Infra 層

2.1 新建檔案

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

2.2 結構(mirror docx 對應檔案)

  • Entity:SspExcelParseJobEntity 跟 model 同欄位 + uid
  • Mapper:to_entity / to_model
  • Repo impl:繼承 BaseRepositoryImpl[Entity, QueryEntity, Model, Mapper],session 自動有
  • Domain service:常用 method
    • create(source_type, source_uid, template_version, metadata, file_name, file_size, tenant_id, user) -> Entity
    • update_status(uid, status, user, file_path=None) -> None
    • write_parsed_result(uid, parsed_result_dict, user) -> None
    • write_error(uid, error_code, error_message, user) -> None
    • write_import_summary(uid, summary, user) -> None
    • get_one(uid) -> Entity | None
    • discard(uid, user) -> None(軟刪 is_active=False)

2.3 Acceptance


§6

Task 3 — Excel Parser 核心

3.1 新建目錄

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

3.2 ParsedExcel 結構(types.py)

@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) 用"""

3.3 ExcelParser.parse 入口

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

3.4 09 sheet 父子 row 邏輯(design-A2 §6.4)

照 design-A2 範例 code 實作;對齊 A1 generator 的「父 row statement_id 空 / 子 row statement_id=AO uid」contract。

3.5 Validators

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。

3.6 Acceptance


§7

Task 4 — App Service skeleton

4.1 新建檔案

app/oscal/service/ssp_excel_import_app_service.py

4.2 結構(mirror SspDocxImportAppService)

_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 補完)

4.3 Acceptance


§8

Task 5 — Route + Serializer

5.1 新建檔案

api/oscal/serializers/ssp/ssp_excel_import.py
api/oscal/routes/ssp/ssp_excel_import_route.py

5.2 Serializer(mirror ssp_docx_import.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()

5.3 Route(mirror ssp_docx_import_route.py)

4 個 Resource:SspExcelImportParseRoute / SspExcelImportRoute (GET+DELETE) / SspExcelImportConfirmRoute。 URL prefix /api/1.0,註冊到 api/oscal/__init__.py 既有 blueprint。

5.4 Acceptance


§9

Task 6 — DI Wiring + Error Code

6.1 DI

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 自動掃描即可。

6.2 Error Code

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")

6.3 Acceptance


§10

Task 7 — Unit Test

7.1 新建測試檔

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

7.2 Parser 測試覆蓋(≥ 25 case)

  • 合法 v1.0.0 / v1.1.0 / v1.2.0 樣板各跑一次 → ParsedExcel 完整
  • v2.0.0 → 抛 GRC_EXCEL_TEMPLATE_VERSION_UNSUPPORTED
  • 缺 00_說明 sheet → 抛 GRC_EXCEL_INVALID_FILE
  • 缺 01-08 sheet 各別 case
  • 03 role 合法 4 值 each
  • 03 role = "admin"/"system-owner"/"管理員"/空 → 對應 ValidationError
  • 07 父 row + 多個子 row:解析成 nested 結構
  • 07 子 row 找不到父 → ValidationError + 跳過
  • 07 同 control_id 重複父 row → 後者覆蓋前者
  • 07 include_in_profile v1.2.0 樣板:TRUE / FALSE / true / "Y" / "是" / 空 → 對應 _target_in_profile
  • 07 include_in_profile v1.0/v1.1 樣板 fallback:所有父 row _target_in_profile=True(向後相容)
  • VLOOKUP cached None → fallback 邏輯
  • 01 sheet hidden col framework_version_uid 讀出
  • Validation errors 列表結構正確

7.3 App Service 測試覆蓋(≥ 20 case,mock domain)

  • upload_and_parse:副檔名錯 / 檔案過大 / source_type 錯 / source_uid 缺 / source 不存在
  • upload_and_parse 走通:parsed_result 寫入 parse_job + return 正確 dict
  • get_parse_result:TTL 過期、is_active=False、跨 tenant 都 404
  • discard_parse:軟刪後再 get → 404
  • confirm_import:status 不是 awaiting_review → 412
  • confirm_import 帶 blocking validation_error → 400
  • confirm_import superset flow:mock ProfileService.add_profile 被呼叫,include_controls 只含 _target_in_profile=True 的 control_id
  • confirm_import superset flow:add_module_frame 用 add_profile 返回的 profile_uid
  • confirm_import update flow:忽略 07 sheet 的 _target_in_profile,既有 MF profile 不動,parties/items upsert 被呼叫
  • confirm_import TTL 過期 → 412
  • _write_parties role enum 已被 validator 擋(mock:role invalid 進不到此 helper)

7.4 Mapper / Domain Service smoke

  • Mapper 雙向 round-trip OK(entity ↔︎ model)
  • Repo create / get_one / update_status 走得通(fixture transaction scope)

7.5 Acceptance


§11

Task 8 — BE Smoke + Changelog + Tracker

8.1 BE Smoke

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>;

8.2 Changelog(兩份)

Changelog #1docs/changelog/YYYY-MM-DD-tweak-ssp-import-template-v1.2.0.md

  • frontmatter:type: tweak, modules: [ssp-import-template], breaking: false
  • 樣板 minor bump v1.1.0 → v1.2.0:07 sheet 加 include_in_profile checkbox
  • A1 generator code patch + unit test fixture column letter 重排
  • 動機:A2 parser superset flow 需要明確的「納入 profile」標記

Changelog #2docs/changelog/YYYY-MM-DD-feat-ssp-excel-import.md

  • frontmatter:type: feat, modules: [ssp-excel-import, oscal], issue: docs/issues/pending/2026-05-19-person-role-cross-domain-inconsistency.md
  • 需求說明(A2 phase 目標)
  • 變更範圍(檔案清單)
  • API 變更(4 個新 endpoint)
  • 兩條 import flow 差異 + MF update flow 不支援改 profile(已知限制 §12 follow-up)
  • 對 issue 修補 A 的處理(role enum validation 已落地)
  • 測試結果(pytest output 摘要)

8.3 Tracker

docs/features/FR-011.2-2605-ssp-import-export-phase2/README.md:A2 row 從 pendingshipped,補 commit / changelog 連結。

8.4 Acceptance


§12

規範遵守清單(執行時逐項勾)


§13

風險檢核點


下一步:等使用者 review design-A2 + plan-A2 → GO → 進 Task 0 verify。