# SSP DOCX Template Rebuild Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 將 SSP DOCX 匯出格式從「flat table 全控制項一覽」升級為對齊 CMMC 標準 SSP 文件的正式格式：每個控制項獨立章節（Heading + AO 表格 + 實作說明），封面改為正式表格，各角色人員各一張表。

**Architecture:** 採 hybrid 架構——docxtpl 負責 Cover / Revision / Introduction / System Environment（靜態結構，template 預先建好），generator 在 `tpl.render()` 後呼叫 `tpl.get_docx()` 取得 python-docx Document，再動態 append 控制項章節與 Reference Documents。Template 不再包含控制項與 Reference Docs loop。

**Tech Stack:** python-docx 1.x, docxtpl 0.17.x, `docxtpl.DocxTemplate.get_docx()` (已確認存在)

---

## 變更範圍一覽

| 檔案 | 變更類型 | 說明 |
|------|----------|------|
| `app/oscal/service/export/ssp_export_model.py` | Modify | 新增 `role_id` 欄位到 `SspPartyExportItem`；新增 `control_requirement` 到 `SspControlImplExportItem` |
| `app/oscal/service/export/ssp_docx_generator.py` | Major rewrite | `_build_context` 改為 per-role party dict；新增 `_append_control_sections` / `_append_reference_docs`；`generate()` 改用 `get_docx()` |
| `scripts/generate_ssp_docx_template.py` | Major rewrite | 新封面、per-role party 靜態表格；移除控制項 loop 與 reference docs |
| `app/oscal/templates/ssp/ssp_cmmc_template.docx` | Regenerate | 重跑 template 腳本產生 |
| `app/oscal/service/export/ssp_mf_content_loader.py` | Modify | `_build_parties` 填 `role_id`；`_build_control_implementations` 填 `control_requirement` |
| `app/oscal/service/export/ssp_version_content_loader.py` | Modify | `_build_parties` 填 `role_id`；`_build_control_implementations` 填 `control_requirement` |

---

## Task 1：更新 export model

**Files:**
- Modify: `app/oscal/service/export/ssp_export_model.py`

- [ ] **Step 1.1：在 `SspPartyExportItem` 加 `role_id` 欄位**

```python
@dataclass
class SspPartyExportItem:
    name: str
    party_type: str                  # "person" | "organization"
    email: Optional[str] = None
    title: Optional[str] = None
    phone: Optional[str] = None
    address: Optional[str] = None
    org_name: Optional[str] = None
    role: Optional[str] = None
    role_id: Optional[str] = None    # OSCAL role ID，用於 per-role 表格配對
```

- [ ] **Step 1.2：在 `SspControlImplExportItem` 加 `control_requirement` 欄位**

```python
@dataclass
class SspControlImplExportItem:
    control_id: str
    control_name: Optional[str] = None
    control_requirement: Optional[str] = None  # catalog 的 requirement 說明文字
    description: Optional[str] = None
    status: Optional[str] = None
    control_origination: Optional[str] = None
    remarks: Optional[str] = None
    objectives: list[SspAoExportItem] = field(default_factory=list)
```

- [ ] **Step 1.3：確認現有程式碼不受影響（新增欄位都有預設值）**

```bash
cd /path/to/compliance-manager-be
python -c "from app.oscal.service.export.ssp_export_model import SspPartyExportItem, SspControlImplExportItem; print('OK')"
```
預期輸出：`OK`

---

## Task 2：重建 template 腳本——封面 + per-role party 表格

**Files:**
- Modify: `scripts/generate_ssp_docx_template.py`

### 設計說明
新 template 結構（靜態部分）：
1. 封面表格（4行 x 3欄，模仿 reference DOCX 格式）
2. Revision History 表格
3. Introduction
   - System Information kv table
   - Responsible Organization 靜態 2-col 表格（`{{ org.name }}` 等）
   - Information Owner 靜態 2-col 表格
   - Information Provider / Receiver / System Owner / Security Officer（各一張）
4. System Environment
   - Network Architecture（`{{ network_architecture }}`）
   - Data Flow（`{{ data_flow }}`）
   - System Components loop table
   - Hardware Inventory loop table
   - Leveraged External Services loop table
5. （結束，controls 與 reference docs 由 generator 動態 append）

- [ ] **Step 2.1：更新 `build_template()` — 封面改為正式表格**

替換目前純文字封面為：

```python
def add_cover(doc):
    tbl = doc.add_table(rows=4, cols=3)
    tbl.style = "Table Grid"
    # Row 0: Ser. NO
    set_cell_text(tbl.rows[0].cells[0], "Ser. NO：", bold=True)
    tbl.rows[0].cells[1].merge(tbl.rows[0].cells[2])
    set_cell_text(tbl.rows[0].cells[1], "{{ system_id }}")
    # Row 1: Version
    set_cell_text(tbl.rows[1].cells[0], "Version：", bold=True)
    tbl.rows[1].cells[1].merge(tbl.rows[1].cells[2])
    set_cell_text(tbl.rows[1].cells[1], "{{ version }}")
    # Row 2: Issue Date
    set_cell_text(tbl.rows[2].cells[0], "Issue Date：", bold=True)
    tbl.rows[2].cells[1].merge(tbl.rows[2].cells[2])
    set_cell_text(tbl.rows[2].cells[1], "{{ export_date }}")
    # Row 3: Title（全欄合併）
    c0, c1, c2 = tbl.rows[3].cells
    c0.merge(c1).merge(c2)
    set_cell_text(
        tbl.rows[3].cells[0],
        "System Security Plan (SSP)\n系統安全計畫",
        bold=True, font_size=16,
    )
    tbl.rows[3].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
    doc.add_page_break()
```

- [ ] **Step 2.2：加入 `add_party_table(doc, heading, role_var)` helper**

每個 role 用同一個 helper 產生一張 2-col 靜態表：

```python
def add_party_table(doc, heading: str, role_var: str):
    """role_var e.g. 'info_owner' → template vars {{ info_owner.name }} etc."""
    h2(doc, heading)
    tbl = doc.add_table(rows=5, cols=2)
    tbl.style = "Table Grid"
    rows_spec = [
        ("Name:", f"{{{{ {role_var}.name }}}}"),
        ("Title:", f"{{{{ {role_var}.title }}}}"),
        ("Office Address:", f"{{{{ {role_var}.address }}}}"),
        ("Work Phone:", f"{{{{ {role_var}.phone }}}}"),
        ("e-Mail Address:", f"{{{{ {role_var}.email }}}}"),
    ]
    for i, (label, val) in enumerate(rows_spec):
        set_cell_text(tbl.rows[i].cells[0], label, bold=True)
        set_cell_text(tbl.rows[i].cells[1], val)
```

組織用另一個 helper（欄位不同）：

```python
def add_org_table(doc, heading: str, role_var: str):
    h2(doc, heading)
    tbl = doc.add_table(rows=3, cols=2)
    tbl.style = "Table Grid"
    for i, (label, val) in enumerate([
        ("Name:", f"{{{{ {role_var}.name }}}}"),
        ("Address:", f"{{{{ {role_var}.address }}}}"),
        ("Phone:", f"{{{{ {role_var}.phone }}}}"),
    ]):
        set_cell_text(tbl.rows[i].cells[0], label, bold=True)
        set_cell_text(tbl.rows[i].cells[1], val)
```

- [ ] **Step 2.3：更新 Introduction 章節使用 per-role party 表格**

```python
h1(doc, "Introduction 基本資料")

h2(doc, "System Information 系統資訊")
add_kv_table(doc, [
    ("System Name 系統名稱",              "{{ system_name }}"),
    ("Abbreviation 簡稱",                 "{{ system_name_short }}"),
    ("System Categorization 系統類別",    "{{ sensitivity_level }}"),
    ("System Unique Identifier 識別號碼", "{{ system_id }}"),
    ("Deployment Model 部署模型",         "{{ deployment_model }}"),
    ("Authorization Boundary 授權邊界",   "{{ authorization_boundary }}"),
])

h2(doc, "General Description / Purpose of System 系統一般說明 / 用途")
doc.add_paragraph("{{ general_description }}")

add_org_table(doc,
    "Responsible Organization 專案負責單位", "org")
add_party_table(doc,
    "Information Owner 資料所有者", "info_owner")
add_party_table(doc,
    "Information Provider 資料提供者", "info_provider")
add_party_table(doc,
    "Information Receiver 資料接收者", "info_receiver")
add_party_table(doc,
    "System Owner 系統所有者", "system_owner")
add_party_table(doc,
    "System Security Officer 系統安全官", "security_officer")
```

- [ ] **Step 2.4：更新 System Environment 章節（加 Network Architecture / Data Flow 段落）**

```python
h1(doc, "SYSTEM ENVIRONMENT 系統環境")

h2(doc, "Network Architecture 網路架構")
doc.add_paragraph("{{ network_architecture }}")

h2(doc, "Data Flow 資料流")
doc.add_paragraph("{{ data_flow }}")

h2(doc, "System Components 系統元件")
add_loop_table(doc,
    headers=["Name 名稱", "Type 類型", "Description 說明", "Status 狀態"],
    loop_var="components",
    cell_vars=["{{ item.name }}", "{{ item.component_type }}", "{{ item.description }}", "{{ item.status }}"],
)

h2(doc, "Hardware Inventory 設備清單")
add_loop_table(doc,
    headers=["Name 名稱", "Type 類型", "IP Address", "OS", "Status 狀態"],
    loop_var="inventory_items",
    cell_vars=["{{ item.name }}", "{{ item.device_type }}", "{{ item.ip_address }}", "{{ item.os }}", "{{ item.status }}"],
)

h2(doc, "Leveraged External Services 外部利用服務")
add_loop_table(doc,
    headers=["Service Title 服務名稱", "Provider 提供方", "Date Authorized 授權日期"],
    loop_var="leveraged_authorizations",
    cell_vars=["{{ item.title }}", "{{ item.provider_name }}", "{{ item.date_authorized }}"],
)
# 注意：控制項與 Reference Docs 由 generator 動態 append，template 到此結束
```

- [ ] **Step 2.5：移除原 build_template 中的 Control Implementation 與 Appendix 章節**

確認 `build_template()` 末段不再有 `add_loop_table(..., "control_implementations", ...)` 和 `add_loop_table(..., "all_objectives", ...)` 及 Appendix 章節。

- [ ] **Step 2.6：執行腳本產生新 template**

```bash
python scripts/generate_ssp_docx_template.py
```

預期：`✅ Template saved to: .../ssp_cmmc_template.docx`

---

## Task 3：重寫 generator `_build_context`——per-role party dict

**Files:**
- Modify: `app/oscal/service/export/ssp_docx_generator.py`

- [ ] **Step 3.1：加入 `_party_by_role()` helper**

```python
@staticmethod
def _party_by_role(parties: list, role_id: str, is_org: bool = False) -> dict:
    """從 parties 清單找第一個符合 role_id 的 party，回傳 template 用 dict。"""
    for p in parties:
        if p.role_id == role_id or p.role == role_id:
            if is_org:
                return {
                    "name": p.name or "",
                    "address": p.address or "",
                    "phone": p.phone or "",
                }
            return {
                "name": p.name or "",
                "title": p.title or "",
                "email": p.email or "",
                "phone": p.phone or "",
                "address": p.address or "",
            }
    empty = {"name": "", "address": "", "phone": ""}
    if not is_org:
        empty.update({"title": "", "email": ""})
    return empty
```

- [ ] **Step 3.2：更新 `_build_context()` — 用 per-role dict 取代 flat organizations/persons**

```python
def _build_context(self, model: SspExportDataModel, author: str) -> dict:
    p = model.parties
    return {
        # metadata
        "title": model.title,
        "system_name": model.system_name,
        "system_name_short": model.system_name_short or "",
        "system_id": model.source_uid or "",
        "version": model.version or "1.0",
        "export_date": date.today().isoformat(),
        "author": author,
        "general_description": model.description or "",
        "sensitivity_level": model.sensitivity_level or "",
        "authorization_boundary": model.authorization_boundary or "",
        "network_architecture": model.network_architecture or "",
        "data_flow": model.data_flow or "",
        "deployment_model": model.deployment_model or "",

        # per-role parties（各對應 template 中的靜態表格）
        "org":              self._party_by_role(p, "responsible-organization", is_org=True),
        "info_owner":       self._party_by_role(p, "information-owner"),
        "info_provider":    self._party_by_role(p, "information-provider"),
        "info_receiver":    self._party_by_role(p, "information-receiver"),
        "system_owner":     self._party_by_role(p, "system-owner"),
        "security_officer": self._party_by_role(p, "security-officer"),

        # system implementation（loop tables 在 template）
        "inventory_items": [...],   # 同舊邏輯
        "components": [...],        # 同舊邏輯
        "leveraged_authorizations": [...],  # 同舊邏輯

        # 控制項與 reference docs 已移至 generator 動態 append，這裡不需要
    }
```

注意：`inventory_items` / `components` / `leveraged_authorizations` 的組裝邏輯完全不動，直接從舊 `_build_context` 複製。

---

## Task 4：加入動態控制項章節 builder

**Files:**
- Modify: `app/oscal/service/export/ssp_docx_generator.py`

### 設計說明
每個控制項產生以下結構：
```
[Heading 3] {control_id} – {control_name}
[Normal]    {control_requirement}（catalog requirement 說明，可能空白）
[Bold Para] Objective / 評估目標：
[Table 2-col] | statement_id | AO description |
[Bold Para] Implementation Description 實作說明：
[Normal]    {user description}
[空行]
```

- [ ] **Step 4.1：新增 `_add_bold_paragraph()` helper**

```python
@staticmethod
def _add_bold_paragraph(doc, text: str):
    p = doc.add_paragraph()
    run = p.add_run(text)
    run.bold = True
```

- [ ] **Step 4.2：新增 `_add_ao_table()` helper**

```python
@staticmethod
def _add_ao_table(doc, objectives: list):
    """2欄 AO 表格：Statement ID | AO Description。"""
    from docx.shared import Pt, Cm
    from docx.oxml.ns import qn
    from docx.oxml import OxmlElement

    if not objectives:
        return

    tbl = doc.add_table(rows=1, cols=2)
    tbl.style = "Table Grid"

    # header row
    hdr = tbl.rows[0]
    hdr.cells[0].text = "Statement"
    hdr.cells[1].text = "Assessment Objective"
    for cell in hdr.cells:
        cell.paragraphs[0].runs[0].bold = True

    # data rows
    for ao in objectives:
        row = tbl.add_row()
        row.cells[0].text = ao.statement_id or ""
        row.cells[1].text = ao.ao_name or ""
```

- [ ] **Step 4.3：新增 `_append_control_sections()` 主方法**

```python
def _append_control_sections(self, doc, model: SspExportDataModel):
    """在 docxtpl 渲染後的 doc 末尾 append 控制項章節。"""
    from docx.oxml import OxmlElement
    from docx.enum.text import WD_BREAK

    doc.add_page_break()
    doc.add_heading("REQUIREMENTS 安全要求", level=1)

    for ctrl in model.control_implementations:
        heading_text = ctrl.control_id
        if ctrl.control_name:
            heading_text += f" – {ctrl.control_name}"
        doc.add_heading(heading_text, level=3)

        if ctrl.control_requirement:
            doc.add_paragraph(ctrl.control_requirement)

        if ctrl.objectives:
            self._add_bold_paragraph(doc, "Objective / 評估目標：")
            self._add_ao_table(doc, ctrl.objectives)

        self._add_bold_paragraph(doc, "Implementation Description 實作說明：")
        doc.add_paragraph(ctrl.description or "")
        doc.add_paragraph("")  # 空行間距
```

- [ ] **Step 4.4：新增 `_append_reference_docs()` 方法**

```python
def _append_reference_docs(self, doc, model: SspExportDataModel):
    """在控制項章節後 append Reference Documents 附錄。"""
    if not model.reference_documents:
        return

    doc.add_page_break()
    doc.add_heading("Appendix: Reference Documents 附錄：參考程序書", level=1)

    tbl = doc.add_table(rows=1, cols=4)
    tbl.style = "Table Grid"
    for i, h in enumerate(["Document Title 文件名稱", "Doc No. 文件編號", "Version 版本", "Type 類型"]):
        cell = tbl.rows[0].cells[i]
        cell.text = h
        cell.paragraphs[0].runs[0].bold = True

    for ref in model.reference_documents:
        row = tbl.add_row()
        row.cells[0].text = ref.title or ""
        row.cells[1].text = ref.doc_no or ""
        row.cells[2].text = ref.version or ""
        row.cells[3].text = ref.doc_type or ""
```

- [ ] **Step 4.5：更新 `generate()` — 改用 `get_docx()` + `doc.save()`**

```python
def generate(self, model: SspExportDataModel, author: str = "") -> BytesIO:
    tpl = DocxTemplate(self._template_path)
    context = self._build_context(model, author)
    tpl.render(context)

    doc = tpl.get_docx()                        # 取得 python-docx Document
    self._append_control_sections(doc, model)   # 動態 append 控制項
    self._append_reference_docs(doc, model)     # 動態 append 附錄

    buf = BytesIO()
    doc.save(buf)                               # 用 python-docx 存檔
    buf.seek(0)
    return buf
```

注意：原本 `tpl.save(buf)` 改成 `doc.save(buf)`，功能等價。

---

## Task 5：更新 MfSspContentLoader — 填 role_id + control_requirement

**Files:**
- Modify: `app/oscal/service/export/ssp_mf_content_loader.py`

- [ ] **Step 5.1：pre-flight 確認 `list_parties()` 回傳資料有 `role` 欄位**

```bash
# 查 module_frame_party_service.list_parties 的回傳結構
grep -n "def list_parties" ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/ -r
# 或查主專案實作
grep -rn "list_parties" app/oscal/ --include="*.py" | head -10
```

確認 `p.get("role")` 的實際值（可能是 "system-owner" 或 "System Owner"）。

- [ ] **Step 5.2：`_build_parties` 填 `role_id`**

```python
def _build_parties(self, mf_uid: str) -> list[SspPartyExportItem]:
    raw = self._party.list_parties(mf_uid)
    result = []
    for p in raw:
        result.append(SspPartyExportItem(
            name=p.get("name") or "",
            party_type=p.get("party_type") or "person",
            email=p.get("email_address"),
            title=p.get("title"),
            phone=p.get("telephone_number"),
            address=p.get("address"),
            org_name=p.get("matched_org_unit_name"),
            role=p.get("role"),
            role_id=p.get("role"),   # 暫時與 role 同值，Step 5.1 確認後調整
        ))
    return result
```

> **注意：** 若 step 5.1 發現 `p.get("role")` 回傳中文顯示名稱（如 "系統所有者"），則 `role_id` 需要另外填 OSCAL 標準 role ID。若目前沒有 OSCAL role_id 可用，則 Task 7 的 smoke test 時 party 表格會留空（graceful degradation，不影響其他 section）。

- [ ] **Step 5.3：`_build_catalog_maps` 加入 control requirement 對應**

```python
@staticmethod
def _build_catalog_maps(mf) -> dict:
    control_title_map: dict[str, str] = {}
    control_req_map: dict[str, str] = {}   # NEW
    ao_desc_map: dict[tuple, str] = {}

    profile = getattr(mf, "oscal_profile", None)
    if profile is None:
        return {"titles": control_title_map, "requirements": control_req_map, "ao_descs": ao_desc_map}

    for pc in (getattr(profile, "profile_controls", None) or []):
        cc = getattr(pc, "catalog_control", None)
        if cc is None:
            continue
        cid = getattr(cc, "control_id", None) or ""
        control_title_map[cid] = getattr(cc, "control_title", "") or ""
        control_req_map[cid] = getattr(cc, "description", "") or ""  # NEW

        for a in (getattr(cc, "assessments", None) or []):
            # ... 同舊邏輯 ...
            pass

    return {"titles": control_title_map, "requirements": control_req_map, "ao_descs": ao_desc_map}
```

- [ ] **Step 5.4：`_build_control_implementations` 填 `control_requirement`**

```python
result.append(SspControlImplExportItem(
    control_id=cid,
    control_name=control_title_map.get(cid, ""),
    control_requirement=catalog_maps.get("requirements", {}).get(cid, ""),  # NEW
    description=ctrl.implementation_description,
    status=ctrl.implementation_status,
    control_origination=ctrl.control_origination,
    remarks=ctrl.remarks,
    objectives=objectives,
))
```

---

## Task 6：更新 SspVersionContentLoader — 填 role_id + control_requirement

**Files:**
- Modify: `app/oscal/service/export/ssp_version_content_loader.py`

- [ ] **Step 6.1：pre-flight 確認 `ssp.oscal_metadata` 有 `responsible_parties`**

```bash
grep -rn "responsible_parties" ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/ --include="*.py" | head -15
```

確認 `metadata.responsible_parties` 是否存在、其結構（role_id + party_uuid list）。

- [ ] **Step 6.2：`_build_parties` — 從 responsible_parties 填 role_id**

若 `metadata.responsible_parties` 可用：

```python
@staticmethod
def _build_parties(metadata) -> list[SspPartyExportItem]:
    if metadata is None:
        return []
    raw_parties = getattr(metadata, "parties", None) or []
    responsible = getattr(metadata, "responsible_parties", None) or []

    # 建立 party_uuid → role_id 對照
    uuid_to_role: dict[str, str] = {}
    for rp in responsible:
        role_id = getattr(rp, "role_id", None) or ""
        for uuid in (getattr(rp, "party_uuids", None) or []):
            uuid_to_role[str(uuid)] = role_id

    result = []
    for p in raw_parties:
        party_uuid = str(getattr(p, "uuid", "") or "")
        result.append(SspPartyExportItem(
            name=getattr(p, "name", "") or "",
            party_type=getattr(p, "party_type", "person") or "person",
            email=getattr(p, "email_address", None),
            title=getattr(p, "title", None),
            phone=getattr(p, "telephone_number", None),
            address=getattr(p, "address", None),
            org_name=None,
            role=uuid_to_role.get(party_uuid),
            role_id=uuid_to_role.get(party_uuid),  # NEW
        ))
    return result
```

若 Step 6.1 確認 `responsible_parties` 不存在，則 `role_id=None`（graceful degradation）。

- [ ] **Step 6.3：`_build_control_implementations` 填 `control_requirement`**

```python
catalog_control = getattr(ctrl, "catalog_control", None)
control_name = getattr(catalog_control, "control_title", "") if catalog_control else ""
control_requirement = getattr(catalog_control, "description", "") if catalog_control else ""  # NEW

result.append(SspControlImplExportItem(
    control_id=ctrl.control_identifier or "",
    control_name=control_name,
    control_requirement=control_requirement,  # NEW
    description=ctrl.implementation_description,
    status=ctrl.implementation_status,
    objectives=objectives,
))
```

---

## Task 7：重新產生 template + smoke test

**Files:**
- Run: `scripts/generate_ssp_docx_template.py`
- Check: `app/oscal/templates/ssp/ssp_cmmc_template.docx`

- [ ] **Step 7.1：重跑 template 產生腳本**

```bash
python scripts/generate_ssp_docx_template.py
```

預期：`✅ Template saved to: .../ssp_cmmc_template.docx`

- [ ] **Step 7.2：確認 BE 正在執行（若未啟動則重啟）**

```bash
lsof -ti:8000 || (lsof -ti:8000 | xargs kill -9; python main_socketio.py &)
```

- [ ] **Step 7.3：smoke test — MF 來源 DOCX 匯出**

```bash
curl -s -o /tmp/test_ssp.docx \
  -H "Authorization: Bearer <token>" \
  -H "X-Tenant-Id: 102" \
  -H "X-Org-Unit-Id: 98" \
  "http://localhost:8000/api/1.0/module-frame/<mf_uid>/ssp-export?format=docx"
```

驗證（用 python-docx）：
```python
from docx import Document
doc = Document("/tmp/test_ssp.docx")
headings = [p.text for p in doc.paragraphs if "Heading" in p.style.name]
print(headings)
# 預期：['Introduction 基本資料', 'REQUIREMENTS 安全要求', 'AC.1.001 – Control Name', ...]
```

- [ ] **Step 7.4：smoke test — SSP 版本來源 DOCX 匯出**

```bash
curl -s -o /tmp/test_ssp_version.docx \
  -H "Authorization: Bearer <token>" \
  -H "X-Tenant-Id: 102" \
  -H "X-Org-Unit-Id: 98" \
  "http://localhost:8000/api/1.0/ssp/<ssp_uid>/export?format=docx"
```

- [ ] **Step 7.5：OSCAL 格式確認不受影響**

```bash
curl -s "http://localhost:8000/api/1.0/ssp/<ssp_uid>/export?format=json" | python -m json.tool | head -20
```

預期：JSON 正常回傳（OSCAL 路徑未修改，不受本次改動影響）

---

## Task 8：Changelog + commit

- [ ] **Step 8.1：寫 changelog**

建立 `docs/changelog/2026-05-22-feat-ssp-docx-template-rebuild.md`：

```yaml
---
type: feat
modules: [oscal, ssp-export]
commit:
---
```

說明：SSP DOCX 匯出格式升級 — 對齊 CMMC 標準 SSP 文件格式，每個控制項獨立章節（Heading 3 + AO 表格 + 實作說明）；封面改為正式四行表格；各角色人員（Information Owner / Provider / Receiver / System Owner / SSO）各一張獨立表格。

- [ ] **Step 8.2：commit**

```bash
git add app/oscal/service/export/ssp_export_model.py \
        app/oscal/service/export/ssp_docx_generator.py \
        app/oscal/service/export/ssp_mf_content_loader.py \
        app/oscal/service/export/ssp_version_content_loader.py \
        scripts/generate_ssp_docx_template.py \
        app/oscal/templates/ssp/ssp_cmmc_template.docx \
        docs/changelog/2026-05-22-feat-ssp-docx-template-rebuild.md
git commit -m "feat(ssp-export): rebuild DOCX template to CMMC SSP format (per-control sections + per-role parties)"
```

---

## 已知限制與後續優化

| 項目 | 現況 | 後續改進 |
|------|------|----------|
| AO ZH 翻譯 | AO 表格只有 EN | 若 catalog 補 ZH 翻譯欄位，加入右欄 |
| 控制項 requirement 說明 | 取 `catalog_control.description`，若空白則不顯示 | 確認 catalog data 是否有此欄位 |
| Party role_id 配對 | 依 Step 5.1 / 6.1 實際查到的欄位決定；若查不到則 party 表格留空 | 長期補充 role_id 對照映射 |
| 控制項排序 | 按 service 回傳順序 | 可加 `sort by control_id` |
