FR-043 稽核計畫 docx 匯入 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: 讓 PM / 稽核單位在 ap-authoring 頁上傳顧問的稽核報告 docx,系統解析成行程預填、預覽修改後確認寫入 AP(走既有 set_tasks / add_ap_party)。

Architecture: 全面 mirror SSP docx 匯入三段式管線(parse → preview → confirm),新增 oscal.ap_docx_parse_jobs 表承載暫存解析結果(TTL 24h),parser 採規則式 adapter registry(本期只有亞航 CMMC L1 v1),confirm 不另開 write path —— 組好 _TaskItem payload 後呼叫既有 AssessmentPlanAppService.set_tasks()(append 語意)與 add_ap_party()(同名走配對)。整個 confirm 在單一 @transaction(已驗證 reentrant)內,失敗整筆 rollback。

Tech Stack: Python 3.11 / Flask-RESTful (MethodResource) / marshmallow / SQLAlchemy 2.x (Mapped) / dependency-injector / python-docx / pytest;FE Vue 3 Composition API + PrimeVue 3.53。


§1

0. Pre-flight 結論(開工前提,已於 plan 撰寫階段驗證)

  • set_tasks / add_ap_party 簽名、_TaskItem 形狀、ssp_docx_parse_job 表/entity/repo、_DOCX_MAX_SIZE(20MB) / _PARSE_JOB_TTL_HOURS(24h)、樣本 docx 全部與 design.md 一致 ✅
  • @transaction reentrant(jedi_common db.py:106:已在 transaction 內則直接執行、不再開 scope/不中途 commit)→ confirm 呼叫 set_tasks/add_ap_party 為單一原子交易 ✅
  • add_ap_party 對同名 party raise ConflictError(非 idempotent)→ confirm 內同名一律視為「配對既有」,不呼叫 add_ap_party
  • spec 措辭修正(不動 design 主結論):樣本 docx 章節標題是無編號的 [Normal] 段落(依據 / 目的 / 稽核單位 / 稽核日期 / 稽核範圍 / 稽核方法 / 稽核結果),parser 以標題字串辨識章節,非樣式或編號。
  • jedi-* 零異動。實作中若發現非動不可 → 停下回報。
§2

1. 決策紀錄(實作時照此,不重新發明)

決策 選擇 理由
parse job 表欄位 精簡版(不含 mode / framework NOT NULL) AP parser 靠 adapter registry 自動偵測格式,mode/framework 對 AP 無意義;避免死欄位(CLAUDE.md 禁 dead column)
模組歸屬 entity/domain/infra 放 grc 模組(domain/grc/infra/grc/),table 落 oscal schema app service 在 app/grc/,cohesion 跟著 app service 走;schema ≠ module(table 與 sibling parse job 同放 oscal schema,符合 design §4.1)
DI 掛載 GrcContainerdi_containers/grc/grc_containers.py 與被復用的 assessment_plan_app_service 同容器,直接注入
gate 復用 AssessmentPlanAppService._resolve_ap_and_check_auditor 改名為 public resolve_ap_and_check_auditor(更新 4 處內部 call site)+ 新增 public resolve_ap_and_check_participant(preview 用,任一 participant) design §4.2「提為 public helper,不跨 service 呼叫私有、不複刻條件」
confirm 寫入 呼叫既有 add_ap_party(新 party)+ set_tasks(append) design §4.1「禁止重複造輪子」
append 轉換 confirm 內讀既有 _ap_detail(ap)["tasks"] → 轉成 _TaskItem shape → 串接新行程 → 全量覆寫 _ap_detail 讀 shape ≠ _TaskItem 寫 shape(design §4.1)
錯誤碼 復用既有 job/phase/auth 碼;只新增 2 個 AP 專用 400(檔案過大 20MB、格式無法辨識) design §4.3
parse job 持久化欄位 parsed_result = ParsedApReport 的 dict(含 adapter_version / suggested payload / party 配對建議) 單一 JSONB 承載,preview 讀它組 response
§3

2. File Structure

BE(compliance-manager-be

動作 路徑 職責
Create scripts/sql/2026-07-02-ap-docx-parse-jobs.sql oscal.ap_docx_parse_jobs + index + RLS + GRANT cm_app + schema_migrations
Create domain/grc/entities/ap_docx_parse_job_entity.py ApDocxParseJobEntity + ApDocxParseJobQueryEntity
Create domain/grc/repository/i_ap_docx_parse_job_repo.py repo interface
Create domain/grc/service/ap_docx_parse_job_domain_service.py ApDocxParseJobDomainService(create / get_one / update_status / write_parsed_result / write_error / write_import_summary / deactivate)
Create infra/grc/model/ap_docx_parse_job.py ORM model(__table_args__ = {"schema": "oscal"}
Create infra/grc/mapper/ap_docx_parse_job_mapper.py entity ↔︎ model mapper
Create infra/grc/repository/ap_docx_parse_job_repo_impl.py BaseRepositoryImpl 子類(含 update/deactivate override,mirror ssp 版)
Create app/grc/service/ap_report_parser/__init__.py export registry + adapter base + ParsedApReport
Create app/grc/service/ap_report_parser/base.py ParsedApReport dataclass + ApReportParserAdapter ABC
Create app/grc/service/ap_report_parser/registry.py ApReportParserRegistry(依序 detect → 第一命中 parse)
Create app/grc/service/ap_report_parser/airasia_cmmc_l1_v1.py 亞航 CMMC L1 v1 adapter(header-label 章節切分)
Create app/grc/service/ap_docx_import_app_service.py ApDocxImportAppService(upload_and_parse / get_parse_result / discard_parse / confirm_import)
Create api/project/serializers/ap_docx_import.py Parse / Preview / Confirm schema
Create api/project/routes/ap_docx_import_route.py 4 個 MethodResource(upload / get+delete / confirm)
Modify api/project/__init__.py 註冊 4 條 route
Modify app/grc/service/assessment_plan_app_service.py 私有 gate → public ×2;4 處 call site 改名
Modify common/code/grc_error_code.py +2 個 AP docx 400 碼
Modify di_containers/grc/grc_containers.py wire parse-job repo/domain + adapter registry + app service;upload_file_container DependenciesContainer
Modify di_containers/containers.py root grc_container block 傳入 upload_file_container=upload_file_container(grc 目前拿不到 file_upload_service,見 A7)
Create test/fixtures/ap_docx/airasia_cmmc_l1_sample.docx 樣本 docx(從桌面複製,去識別化非必要—內部測試)
Create test/test_ap_report_parser_airasia.py parser snapshot 測試
Create test/test_ap_docx_import_app_service.py app service 權限矩陣 / TTL / append / 同名 party

FE(compliance-manager-fe,Phase D)

動作 路徑 職責
Create src/components/grc/ap-docx-import/ApDocxImportDialog.vue 三步 wizard dialog
Create src/composables/useApDocxDraft.js localStorage draft(mirror useSspDocxDraft,key by parse_uid)
Create src/service/ApDocxImportService.js api 呼叫封裝(繼承 BaseService)
Modify src/config/api/api.js +AP docx import 端點常數
Modify src/views/project/RoundApAuthoringView.vue header「匯入稽核計畫」按鈕 + 掛 dialog + 成功後 refresh
Modify FE i18n(zh_Hant_TW / en 對話框文案 + 新 error code 文案

測試專案(compliance-manager-test,Phase E):由 feature-test-plannertest-plan.md,e2e feature/step/page 一律在此 repo。


§4

Phase A — BE 基建(parse job 表 + entity/model/mapper/repo/domain + DI)

Task A1: SQL migration — oscal.ap_docx_parse_jobs

Files:

  • Create: scripts/sql/2026-07-02-ap-docx-parse-jobs.sql
-- Date: 2026-07-02
-- =============================================================================
-- FR-043 稽核計畫 docx 匯入 — ap_docx_parse_jobs 表
-- 1. 新建 oscal.ap_docx_parse_jobs(AP docx 解析工作記錄,TTL 24h)
-- 2. index(source / status / tenant)
-- 3. RLS + policy
-- 4. GRANT cm_app(表 + sequence)
-- 5. INSERT schema_migrations
-- =============================================================================

-- 1. 建表 (2026-07-02)
CREATE TABLE oscal.ap_docx_parse_jobs (
    id             SERIAL PRIMARY KEY,
    uid            VARCHAR(36) NOT NULL UNIQUE,                 -- UUIDv4(= parse_uid)
    tenant_id      INTEGER NOT NULL,                            -- RLS
    source_type    VARCHAR(20) NOT NULL DEFAULT 'ap',           -- 固定 'ap'
    source_uid     VARCHAR(36) NOT NULL,                        -- ap.uid
    status         VARCHAR(20) NOT NULL DEFAULT 'pending',       -- pending|awaiting_review|completed|failed|expired|discarded
    file_path      VARCHAR(255),                                 -- file upload uid
    file_name      VARCHAR(255),
    file_size      BIGINT,
    parsed_result  JSONB,                                        -- ParsedApReport + suggested payload + party 配對建議
    error_code     VARCHAR(40),
    error_message  TEXT,
    import_summary JSONB,                                        -- confirm 後寫入
    is_active      BOOLEAN NOT NULL DEFAULT TRUE,                -- 軟刪除
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_user   VARCHAR(50) NOT NULL,
    updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_user   VARCHAR(50) NOT NULL
);

-- 2. index (2026-07-02)
CREATE INDEX idx_ap_docx_parse_jobs_source ON oscal.ap_docx_parse_jobs(source_type, source_uid);
CREATE INDEX idx_ap_docx_parse_jobs_status ON oscal.ap_docx_parse_jobs(status, created_at);
CREATE INDEX idx_ap_docx_parse_jobs_tenant ON oscal.ap_docx_parse_jobs(tenant_id);

-- 3. RLS (2026-07-02)
ALTER TABLE oscal.ap_docx_parse_jobs ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_ap_docx_parse_jobs ON oscal.ap_docx_parse_jobs
    USING (tenant_id::text = current_setting('app.user_id', true)
        OR current_setting('app.allowed_tenant_paths', true) LIKE '%' || tenant_id::text || '%');

-- 4. GRANT cm_app (2026-07-02)
GRANT SELECT, INSERT, UPDATE, DELETE ON oscal.ap_docx_parse_jobs TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE oscal.ap_docx_parse_jobs_id_seq TO cm_app;

-- 5. schema_migrations (2026-07-02) — 真實表為 (filename, note) PK,非 version
INSERT INTO public.schema_migrations(filename, note) VALUES
  ('2026-07-02-ap-docx-parse-jobs.sql', 'FR-043 Task A1 / oscal.ap_docx_parse_jobs')
ON CONFLICT (filename) DO NOTHING;

✅ A1 已完成(commit bb9e24f):已套 DEV、RLS 驗證通過。

Run:

PGPASSWORD='<查 .env>' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
  --single-transaction -v ON_ERROR_STOP=1 -f scripts/sql/2026-07-02-ap-docx-parse-jobs.sql

Expected: CREATE TABLEGRANTINSERT 0 1,無 error。

Run:

PGPASSWORD='<查 .env>' psql -h 192.168.50.188 -p 25432 -U cm_app -d guidant_ai_dev -c \
  "SELECT count(*) FROM oscal.ap_docx_parse_jobs;"

Expected: 0(cm_app 有權限、RLS 通)。

git add scripts/sql/2026-07-02-ap-docx-parse-jobs.sql
git commit -m "feat(FR-043): ap_docx_parse_jobs 表 migration(RLS + GRANT cm_app)"

STG / POC / PROD 待整個 feature 完成、user 指示後再套(比照專案慣例,dev 先行)。

Task A2: Entity + Query

Files:

  • Create: domain/grc/entities/ap_docx_parse_job_entity.py
from datetime import datetime
from typing import Optional


class ApDocxParseJobEntity:
    """AP docx 解析任務 Domain Entity(FR-043)。"""

    def __init__(
        self,
        id: Optional[int] = None,
        uid: Optional[str] = None,
        tenant_id: Optional[int] = None,
        source_type: Optional[str] = "ap",
        source_uid: Optional[str] = None,      # ap.uid
        status: Optional[str] = None,
        file_path: Optional[str] = None,
        file_name: Optional[str] = None,
        file_size: Optional[int] = None,
        parsed_result: Optional[dict] = None,
        error_code: Optional[str] = None,
        error_message: Optional[str] = None,
        import_summary: Optional[dict] = None,
        is_active: bool = True,
        created_at: Optional[datetime] = None,
        created_user: Optional[str] = None,
        updated_at: Optional[datetime] = None,
        updated_user: Optional[str] = None,
    ):
        self.id = id
        self.uid = uid
        self.tenant_id = tenant_id
        self.source_type = source_type
        self.source_uid = source_uid
        self.status = status
        self.file_path = file_path
        self.file_name = file_name
        self.file_size = file_size
        self.parsed_result = parsed_result
        self.error_code = error_code
        self.error_message = error_message
        self.import_summary = import_summary
        self.is_active = is_active
        self.created_at = created_at
        self.created_user = created_user
        self.updated_at = updated_at
        self.updated_user = updated_user


class ApDocxParseJobQueryEntity:
    def __init__(
        self,
        uid: Optional[str] = None,
        tenant_id: Optional[int] = None,
        source_type: Optional[str] = None,
        source_uid: Optional[str] = None,
        status: Optional[str] = None,
        is_active: Optional[bool] = True,
    ):
        self.uid = uid
        self.tenant_id = tenant_id
        self.source_type = source_type
        self.source_uid = source_uid
        self.status = status
        self.is_active = is_active

Task A3: ORM Model

Files:

  • Create: infra/grc/model/ap_docx_parse_job.py
from typing import Optional

from sqlalchemy import BigInteger, Boolean, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

from jedi_common.session.database.model.base_model import BaseModel
from jedi_common.session.database.model.tenant_mixin_model import TenantScopedMixinModel


class ApDocxParseJob(BaseModel, TenantScopedMixinModel):
    """AP docx 解析工作記錄(FR-043)。"""

    __tablename__ = "ap_docx_parse_jobs"
    __table_args__ = {"schema": "oscal"}

    uid: Mapped[str] = mapped_column(String(36), unique=True, nullable=False)
    source_type: Mapped[str] = mapped_column(String(20), nullable=False, default="ap")
    source_uid: Mapped[str] = mapped_column(String(36), nullable=False)
    status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
    file_path: Mapped[Optional[str]] = mapped_column(String(255))
    file_name: Mapped[Optional[str]] = mapped_column(String(255))
    file_size: Mapped[Optional[int]] = mapped_column(BigInteger)
    parsed_result: Mapped[Optional[dict]] = mapped_column(JSONB)
    error_code: Mapped[Optional[str]] = mapped_column(String(40))
    error_message: Mapped[Optional[str]] = mapped_column(Text)
    import_summary: Mapped[Optional[dict]] = mapped_column(JSONB)
    is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)

注意:tenant_id / created_at / created_user / updated_at / updated_userBaseModel + TenantScopedMixinModel 提供。實作時先 Read SspDocxParseJob model 確認 mixin 是否已含 tenant_id(ssp 版沒有另宣告 tenant_id)。

Task A4: Mapper

Files:

  • Create: infra/grc/mapper/ap_docx_parse_job_mapper.py

Task A5: Repo interface + impl

Files:

  • Create: domain/grc/repository/i_ap_docx_parse_job_repo.py
  • Create: infra/grc/repository/ap_docx_parse_job_repo_impl.py

Task A6: Domain service

Files:

  • Create: domain/grc/service/ap_docx_parse_job_domain_service.py
import uuid
from typing import Optional

from domain.grc.entity.ap_docx_parse_job_entity import (
    ApDocxParseJobEntity,
    ApDocxParseJobQueryEntity,
)
from domain.grc.repository.i_ap_docx_parse_job_repo import IApDocxParseJobRepo


class ApDocxParseJobDomainService:
    """AP docx 解析任務 domain service(FR-043)。"""

    def __init__(self, repo: IApDocxParseJobRepo):
        self._repo = repo

    def create(self, source_uid: str, file_name: str, file_size: int,
               tenant_id: int, user: str) -> ApDocxParseJobEntity:
        return self._repo.add(ApDocxParseJobEntity(
            uid=str(uuid.uuid4()), tenant_id=tenant_id,
            source_type="ap", source_uid=source_uid,
            file_name=file_name, file_size=file_size, status="pending",
            created_user=user, updated_user=user,
        ))

    def get_one(self, uid: str) -> Optional[ApDocxParseJobEntity]:
        return self._repo.get_one(ApDocxParseJobQueryEntity(uid=uid))

    def update_status(self, uid: str, status: str, user: str, **fields):
        job = self.get_one(uid)
        if not job:
            return None
        return self._repo.update(job.id, {"status": status, **fields}, user)

    def write_parsed_result(self, uid: str, parsed_result: dict, user: str):
        return self.update_status(uid, "awaiting_review", user, parsed_result=parsed_result)

    def write_error(self, uid: str, error_code: str, error_message: str, user: str):
        return self.update_status(uid, "failed", user,
                                  error_code=error_code, error_message=error_message)

    def write_import_summary(self, uid: str, summary: dict, user: str):
        return self.update_status(uid, "completed", user, import_summary=summary)

    def deactivate(self, uid: str, user: str) -> None:
        self._repo.deactivate(uid, user)

Run:

set -a; source .env; set +a
python -c "from domain.grc.service.ap_docx_parse_job_domain_service import ApDocxParseJobDomainService; \
from infra.grc.repository.ap_docx_parse_job_repo_impl import ApDocxParseJobRepoImpl; \
from infra.grc.model.ap_docx_parse_job import ApDocxParseJob; print('ok')"

Expected: ok(無 import error / SQLAlchemy mapper error)。

git add domain/grc/entity/ap_docx_parse_job_entity.py \
        domain/grc/repository/i_ap_docx_parse_job_repo.py \
        domain/grc/service/ap_docx_parse_job_domain_service.py \
        infra/grc/model/ap_docx_parse_job.py \
        infra/grc/mapper/ap_docx_parse_job_mapper.py \
        infra/grc/repository/ap_docx_parse_job_repo_impl.py
git commit -m "feat(FR-043): ap_docx_parse_job entity/model/mapper/repo/domain service"

Task A7: DI wiring

Files:

  • Modify: di_containers/grc/grc_containers.py
from infra.grc.repository.ap_docx_parse_job_repo_impl import ApDocxParseJobRepoImpl
from domain.grc.service.ap_docx_parse_job_domain_service import ApDocxParseJobDomainService
# ...
ap_docx_parse_job_repo = providers.Singleton(ApDocxParseJobRepoImpl)
ap_docx_parse_job_domain_service = providers.Factory(
    ApDocxParseJobDomainService, repo=ap_docx_parse_job_repo,
)
    1. di_containers/grc/grc_containers.pyGrcContainerupload_file_container = providers.DependenciesContainer()
    2. di_containers/containers.py(grc_container providers.Container(...) block,~line 248):加 upload_file_container=upload_file_container, 之後 Phase C app service provider 用 upload_file_container.file_upload_service

§5

Phase B — parser(adapter registry + 亞航 v1,TDD)

Task B1: 樣本 fixture + ParsedApReport / adapter base

Files:

  • Create: test/fixtures/ap_docx/airasia_cmmc_l1_sample.docxcp 桌面樣本)
  • Create: app/grc/service/ap_report_parser/base.py
mkdir -p test/fixtures/ap_docx
cp "/Users/chouraymond/Desktop/AirAsia實際稽核檔案/AP稽核計畫匯入測試檔案/亞航-CMMC L1內部稽核報告20260612(稿).docx" \
   test/fixtures/ap_docx/airasia_cmmc_l1_sample.docx
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Dict, List, Optional


@dataclass
class ParsedApReport:
    """版本無關中間結構(adapter 產出 → app service 組 payload 只依賴這個)。"""
    adapter_version: str
    title: Optional[str] = None
    audit_date: Optional[str] = None          # ISO date "2026-06-02"(無法解析則 None)
    audit_date_text: Optional[str] = None      # 時段全文("上午10:00-12:30")→ 進備註
    audit_org: Optional[str] = None            # "鈊安資安顧問"
    scope_text: Optional[str] = None
    methods: List[str] = field(default_factory=list)             # AUDIT_METHOD value(見 B3)
    method_guidances: Dict[str, str] = field(default_factory=dict)  # {method_value: 查核指引}
    basis_text: Optional[str] = None           # §依據 全文
    objective_text: Optional[str] = None        # §目的 全文
    raw_sections: Dict[str, str] = field(default_factory=dict)   # {header: full text}(除錯/未來擴充)


class ApReportParserAdapter(ABC):
    version: str = ""

    @abstractmethod
    def detect(self, doc) -> bool:
        """章節結構特徵偵測(header-label 命中)。"""

    @abstractmethod
    def parse(self, doc) -> ParsedApReport:
        """解析成版本無關中間結構。"""

Task B2: registry

Files:

  • Create: app/grc/service/ap_report_parser/registry.py
  • Create: app/grc/service/ap_report_parser/__init__.py
from typing import List
import docx  # python-docx

from app.grc.service.ap_report_parser.base import ApReportParserAdapter, ParsedApReport


class ApReportUnsupportedFormat(Exception):
    """全 adapter miss。app service 捕捉 → 400 格式無法辨識 + 支援版本清單。"""
    def __init__(self, supported_versions: List[str]):
        self.supported_versions = supported_versions
        super().__init__("no adapter matched")


class ApReportParserRegistry:
    def __init__(self, adapters: List[ApReportParserAdapter]):
        self._adapters = adapters

    @property
    def supported_versions(self) -> List[str]:
        return [a.version for a in self._adapters]

    def parse_file(self, file_path: str) -> ParsedApReport:
        doc = docx.Document(file_path)
        for adapter in self._adapters:
            if adapter.detect(doc):
                return adapter.parse(doc)
        raise ApReportUnsupportedFormat(self.supported_versions)

Task B3: 亞航 CMMC L1 v1 adapter(TDD)

Files:

  • Create: test/test_ap_report_parser_airasia.py
  • Create: app/grc/service/ap_report_parser/airasia_cmmc_l1_v1.py

AUDIT_METHOD value 對照(開工前先確認):design §3 說 methods 對照 system_menu AUDIT_METHOD。程式碼未 grep 到常數,值存 DB system_menuStep 0(開工先做)

PGPASSWORD='<查 .env>' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c \
  "SELECT code, name FROM public.system_menu WHERE group_code ILIKE '%method%' OR code ILIKE '%interview%';"

用實際 code(如 INTERVIEW/EXAMINE/TEST)當 methods value,別自創字串。若查無 → 停下回報 user(design 假設的 menu 不存在)。

import os
import pytest
from app.grc.service.ap_report_parser.airasia_cmmc_l1_v1 import AirAsiaCmmcL1V1Adapter
import docx

FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures/ap_docx/airasia_cmmc_l1_sample.docx")


@pytest.fixture
def parsed():
    adapter = AirAsiaCmmcL1V1Adapter()
    doc = docx.Document(FIXTURE)
    assert adapter.detect(doc) is True
    return adapter.parse(doc)


def test_title(parsed):
    assert "CMMC 2.0 Level 1內部稽核報告" in parsed.title

def test_audit_date(parsed):
    assert parsed.audit_date == "2026-06-02"
    assert "10:00" in (parsed.audit_date_text or "")

def test_audit_org(parsed):
    assert parsed.audit_org == "鈊安資安顧問"

def test_scope_text(parsed):
    assert "屏東軍機性能提升案" in parsed.scope_text
    assert "AACL-CMMC-SSP-01" in parsed.scope_text

def test_methods(parsed):
    # value 對照 system_menu AUDIT_METHOD(Step 0 查到的實際 code)
    assert set(parsed.methods) >= {"INTERVIEW", "EXAMINE", "TEST"}

def test_method_guidances(parsed):
    # §稽核方法 敘述拆出的每方法查核指引(至少每個 method 有非空指引)
    for m in parsed.methods:
        assert parsed.method_guidances.get(m)

def test_basis_and_objective(parsed):
    assert "CMMC" in parsed.basis_text
    assert "DoW" in parsed.objective_text or "供應鏈" in parsed.objective_text

Run: set -a; source .env; set +a; pytest test/test_ap_report_parser_airasia.py -v Expected: FAIL(module not found / adapter 未實作)。

    • detect:走 doc.paragraphs,收集非空段落文字;命中判準 = 同時出現「依據」「目的」「稽核單位」「稽核日期」「稽核範圍」「稽核方法」這組 header-label(≥5 命中)→ True。
    • parse:以 header-label 段落當切分點,把「該 header 之後、下一 header 之前」的段落聚成 section 文字,存 raw_sections;再逐 section 抽值:
      • 標題 = 第一個非空段落
      • 稽核日期:正則抽 (\d{4})年(\d{2})月(\d{2})日 → ISO;剩餘時段字串進 audit_date_text
      • 稽核單位:去「由…協助辦理」殼取組織名(regex 由(.+?)(協助|辦理|$);fallback 整句)
      • 稽核範圍:section 全文 → scope_text
      • 稽核方法:關鍵詞比對(訪談/Interview→INTERVIEW;文件與政策紀錄檢查/現場實地觀察/Examination→EXAMINE;實體電腦組態設定比對/Technical Configuration Review/Test→TEST)→ methods;把描述該方法用途的整句填 method_guidances[method](同一句涵蓋多方法時各自掛一份)
      • 依據 / 目的:section 全文 → basis_text / objective_text
    • 稽核結果附件 section 忽略(AR 範疇,見 analysis §2.3)。
    • 缺 section → 對應欄位 None / 空 list,不丟例外(丟例外的只有 detect 全 miss,由 registry 處理)。

Run: pytest test/test_ap_report_parser_airasia.py -v Expected: PASS(全 8 test)。

git add test/fixtures/ap_docx/airasia_cmmc_l1_sample.docx test/test_ap_report_parser_airasia.py \
        app/grc/service/ap_report_parser/
git commit -m "feat(FR-043): AP 報告 parser adapter registry + 亞航 CMMC L1 v1(TDD)"

§6

Phase C — app service + serializer + route + error code

Task C1: 錯誤碼

Files:

  • Modify: common/code/grc_error_code.py
GRC_AP_DOCX_FILE_TOO_LARGE   = ("上傳檔案超過大小限制(20MB)", "GRC_400090")
GRC_AP_DOCX_FORMAT_UNSUPPORTED = ("無法辨識的稽核報告格式,目前支援:{versions}", "GRC_400091")

復用既有碼:GRC_DOCX_INVALID_FILE(400005,非 .docx)、GRC_AP_NOT_FOUND(404015)、GRC_DOCX_PARSE_JOB_NOT_FOUND(404023)、GRC_DOCX_PARSE_JOB_NOT_AWAITING(412012)、GRC_DOCX_PARSE_JOB_EXPIRED(412013)、GRC_DOCX_PARSE_FAILED(422003)、GRC_NOT_AUDITOR(權限)、phase gate 既有 412。

Task C2: gate helper 提為 public

Files:

  • Modify: app/grc/service/assessment_plan_app_service.py
def resolve_ap_and_check_participant(self, ap_uid: str, curr_user_id: int):
    """preview 守門:AP 存在 + user 是該 project participant(任一角色,不卡階段)。
    caller 必須在 @transaction scope 內。回傳 ApEntity。"""
    ap = self._ap_repo.get_by_uid(ap_uid)
    if ap is None:
        raise NotFound(GrcErrorCode.GRC_AP_NOT_FOUND)
    rnd = self._rounds.get_by_assessment_plan_id(ap.id)
    if rnd is None:
        raise NotFound(GrcErrorCode.GRC_ROUND_NOT_FOUND)
    participant = self._participant_domain_service.get_one(
        ProjectParticipantQueryEntity(project_id=rnd.project_id, user_id=curr_user_id))
    if participant is None:
        raise ForbiddenError(GrcErrorCode.GRC_NOT_AUDITOR)  # 或更貼切的既有 forbidden 碼
    return ap

實作時確認 ProjectParticipantQueryEntity 已 import;resolve_ap_and_check_auditor 已含 phase + auditor/manager,confirm/discard/parse 用它、preview 用 participant 版。

git add common/code/grc_error_code.py app/grc/service/assessment_plan_app_service.py
git commit -m "feat(FR-043): AP docx error codes + gate helper 提為 public(auditor/participant 兩版)"

Task C3: serializer

Files:

  • Create: api/project/serializers/ap_docx_import.py
from marshmallow import Schema, fields, EXCLUDE
from api.project.serializers.audit_round import _TaskItem  # 復用行程形狀


class ApDocxImportParseResponseSchema(Schema):
    parse_uid = fields.Str()
    status = fields.Str()
    error_code = fields.Str(allow_none=True)
    error_message = fields.Str(allow_none=True)


class ApDocxImportPreviewResponseSchema(Schema):
    """GET /ap-docx-import/<parse_uid>:解析摘要 + 建議 payload + party 配對建議。"""
    parse_uid = fields.Str()
    status = fields.Str()
    adapter_version = fields.Str(allow_none=True)
    ttl_expires_at = fields.Str(allow_none=True)
    error_code = fields.Str(allow_none=True)
    error_message = fields.Str(allow_none=True)
    # 建議行程(單筆,_TaskItem 形狀)+ 章節摘要 + 參考資訊 + party 配對建議
    suggested_task = fields.Dict(allow_none=True)      # _TaskItem shape(FE 直接填表)
    sections = fields.Dict(allow_none=True)            # {header: text} 供 FE 顯示 chips
    reference_info = fields.Dict(allow_none=True)      # {basis_text, objective_text}
    party_suggestion = fields.Dict(allow_none=True)    # {name, matched_party_uid|None, candidates:[...]}


class _ConfirmPartySchema(Schema):
    class Meta:
        unknown = EXCLUDE
    name = fields.Str(required=True)
    matched_party_uid = fields.Str(allow_none=True)    # 非空 = 配對既有;None = 建新


class ApDocxImportConfirmRequestSchema(Schema):
    class Meta:
        unknown = EXCLUDE
    # user 預覽修改後的行程(單筆或多筆,append 到既有)
    tasks = fields.List(fields.Nested(_TaskItem), required=True)
    parties = fields.List(fields.Nested(_ConfirmPartySchema), load_default=list)


class ApDocxImportConfirmResponseSchema(Schema):
    parse_uid = fields.Str()
    status = fields.Str()
    import_summary = fields.Dict()

Task C4: app service(核心)

Files:

  • Create: app/grc/service/ap_docx_import_app_service.py
  • Test: test/test_ap_docx_import_app_service.py
import pytest
from unittest.mock import MagicMock, patch


@pytest.fixture(autouse=True)
def _patch_logger():
    with patch("app.grc.service.ap_docx_import_app_service.logger", MagicMock()):
        yield

# 測試涵蓋(詳細期望由 feature-test-planner Phase E 展開,這裡先放關鍵行為):
# - upload_and_parse: 非 .docx → GRC_DOCX_INVALID_FILE;>20MB → GRC_AP_DOCX_FILE_TOO_LARGE;
#   全 adapter miss → GRC_AP_DOCX_FORMAT_UNSUPPORTED;成功 → status awaiting_review + parse_uid
# - get_parse_result: TTL 過期 → status expired;非 participant → 403
# - confirm_import: 非 awaiting_review → 412;TTL 過期 → 412;
#   append 不覆蓋既有行程;party 同名走配對不呼叫 add_ap_party

要點(實作紀律,逐條照做):

  • 常數_DOCX_MAX_SIZE = 20 * 1024 * 1024_PARSE_JOB_TTL_HOURS = 24(AP 檔案本地定義,不 import oscal 版,避免跨模組耦合)。
  • __init__ 注入parse_job_domain_serviceparser(= ApReportParserRegistry)、file_upload_serviceassessment_plan_app_service(復用 gate + set_tasks + add_ap_party + list_ap_parties + _ap_detail 需要的讀取;_ap_detail 是私有 → 改用 public 讀取:見下方 append 轉換)。
  • upload_and_parse(file, ap_uid, user_context) @transaction
    1. gate:self._ap_svc.resolve_ap_and_check_auditor(ap_uid, user_context.id)(parse 需 manager/auditor + audit_planning 階段)
    2. .docxGRC_DOCX_INVALID_FILE)、size(GRC_AP_DOCX_FILE_TOO_LARGE
    3. job = self._parse_job.create(source_uid=ap_uid, file_name=…, file_size=…, tenant_id=user_context.tenant_id, user=user_context.login_name)
    4. 存暫存檔(tempfile,mirror ssp 版;上傳 file_upload_service 取 file_uid 存 file_path
    5. try: parsed = self._parser.parse_file(tmp_path)except ApReportUnsupportedFormat as e:write_error + 回 {parse_uid, status:"failed", error_code: GRC_AP_DOCX_FORMAT_UNSUPPORTED, error_message: 帶 e.supported_versions}except Exception:write_error(GRC_DOCX_PARSE_FAILED)
    6. 成功:組 parsed_result(見 Step 3 shape)→ write_parsed_result → 清暫存檔 → 回 {parse_uid, status:"awaiting_review"}
  • get_parse_result(parse_uid, user_context) @transaction
    1. job = self._parse_job.get_one;不存在/非 active → 404;tenant 不符且非 admin → 404
    2. self._ap_svc.resolve_ap_and_check_participant(job.source_uid, user_context.id)(preview 任一 participant)
    3. TTL 過期 → update_status(expired) + job.status="expired"
    4. 回攤平 response(parse_uid/status/adapter_version/ttl_expires_at/suggested_task/sections/reference_info/party_suggestion),party_suggestion 的配對建議在此讀路徑算(比對既有 list_ap_parties(ap_uid) 同名)
  • discard_parse(parse_uid, user_context) @transaction:404 檢查 + resolve_ap_and_check_participant不可用 auditor 版 — 它含 assert_round_phase({"audit_planning"}),輪次推進後廢棄殘留 parse job 會誤 412;廢棄是清理,不該卡階段。比照 SSP discard 只 tenant/participant gate)+ deactivate
  • confirm_import(parse_uid, payload, user_context) @transaction
    1. job 404 檢查;job.status != "awaiting_review" → 412 GRC_DOCX_PARSE_JOB_NOT_AWAITING;TTL 過期 → 412 GRC_DOCX_PARSE_JOB_EXPIRED
    2. gate:ap = self._ap_svc.resolve_ap_and_check_auditor(job.source_uid, user_context.id)(雙重保險,set_tasks 內也會再擋)
    3. party 建立/配對:先讀 self._ap_svc.list_ap_parties(job.source_uid)name(casefold)→uid map。對 payload["parties"]matched_party_uid 非空 → 用該 uid;否則 name 已在 map → 取既有 uid(不 call add_ap_party,避免同名 ConflictError 整筆 rollback,design §4.1);否則 res = self._ap_svc.add_ap_party(job.source_uid, {"party_type":"organization","name":name,"role":"assessor"}, curr_user, curr_user_id)res["uid"]add_ap_party 回傳 key 是 uid不是 party_uuid)。收集所有 resolved uid 成 resolved_party_uids
    4. 把 party 接進行程 participants(reviewer #3:否則 org 建了但行程 participant 為空、稽核人員欄不顯示):對 payload["tasks"] 每筆,把 resolved_party_uids 併進其 participants{"party_uuid": uid, "role_id": "assessor"},與既有去重)。
    5. append 轉換:讀既有行程 → 轉 _TaskItem → 串新行程
      existing = self._ap_svc.get_ap_tasks_as_task_items(job.source_uid)  # 見下方 helper
      merged = existing + enriched_tasks  # enriched_tasks = 已接 participants 的 payload tasks
      self._ap_svc.set_tasks(job.source_uid, tasks=merged,
                             curr_user=user_context.login_name, curr_user_id=user_context.id)
    6. write_import_summary(parse_uid, {tasks_appended, parties_created}, user) → 回 {parse_uid, status:"completed", import_summary}
suggested_task = {
    "title": parsed.title,
    "type": "action",
    "timing": parsed.audit_date,   # date 字串;set_tasks 原樣存 ap_tasks.timing
    "methods": parsed.methods,
    "steps": [{"method": m, "description": g} for m, g in parsed.method_guidances.items()],
    "controls": [],                # user 預覽時補勾
    "subjects": [],                # user 預覽時補勾
    "participants": [],            # confirm 時依 party 配對結果補(見下)
    "description": _compose_description(parsed),  # 範圍全文 + 時段 +(勾選時)依據/目的
}

_compose_description:範圍敘述 + 稽核時段(audit_date_text);依據/目的預設附上(FE checkbox 可取消 → confirm 送修改後的 description)。

Modify app/grc/service/assessment_plan_app_service.py

def get_ap_tasks_as_task_items(self, ap_uid: str) -> list:
    """讀既有行程並轉成 set_tasks 可吃的 _TaskItem shape(append 用)。
    caller 必須在 @transaction scope 內。"""
    ap = self._ap_repo.get_by_uid(ap_uid)
    if ap is None:
        raise NotFound(GrcErrorCode.GRC_AP_NOT_FOUND)
    detail = self._ap_detail(ap)
    items = []
    for t in detail["tasks"]:
        items.append({
            "title": t["title"], "type": t.get("type"), "timing": t.get("timing"),
            "methods": t.get("methods") or [],
            "controls": t.get("controls") or [],
            "subjects": t.get("subjects") or [],
            "participants": t.get("participants") or [],
            "steps": t.get("steps") or [],
            "description": t.get("description"),
        })
    return items
git add app/grc/service/ap_docx_import_app_service.py \
        app/grc/service/assessment_plan_app_service.py \
        api/project/serializers/ap_docx_import.py \
        test/test_ap_docx_import_app_service.py
git commit -m "feat(FR-043): ApDocxImportAppService(parse/preview/confirm,append + party 配對)"

Task C5: route + 註冊 + DI app service wire

Files:

  • Create: api/project/routes/ap_docx_import_route.py
  • Modify: api/project/__init__.py
  • Modify: di_containers/grc/grc_containers.py
    • POST /ap/<ap_uid>/docx-imports/parseupload_and_parse
    • GET /ap-docx-import/<parse_uid>get_parse_result
    • DELETE /ap-docx-import/<parse_uid>discard_parse
    • POST /ap-docx-import/<parse_uid>/confirmconfirm_import(load ApDocxImportConfirmRequestSchema
    • DI:Provide[Containers.grc_container.ap_docx_import_app_service](確認 grc_container 在 Containers 的 attr 名)
  • api.add_resource(ApDocxImportUploadRoute, '/ap/<string:ap_uid>/docx-imports/parse')
    api.add_resource(ApDocxImportRoute, '/ap-docx-import/<string:parse_uid>')
    api.add_resource(ApDocxImportConfirmRoute, '/ap-docx-import/<string:parse_uid>/confirm')
  • ap_report_parser_registry = providers.Singleton(
        ApReportParserRegistry,
        adapters=providers.List(providers.Singleton(AirAsiaCmmcL1V1Adapter)),
    )
    ap_docx_import_app_service = providers.Factory(
        ApDocxImportAppService,
        parse_job_domain_service=ap_docx_parse_job_domain_service,
        parser=ap_report_parser_registry,
        file_upload_service=upload_file_container.file_upload_service,
        assessment_plan_app_service=assessment_plan_app_service,
    )
    upload_file_container 已於 A7 Step 3 補進 GrcContainer + root containers.py。)

提醒 user 重啟 BE(無 hot reload)後才可手測。

git add api/project/routes/ap_docx_import_route.py api/project/__init__.py di_containers/grc/grc_containers.py
git commit -m "feat(FR-043): AP docx import route + DI wiring"

§7

Phase D — FE(ap-authoring dialog wizard)

動工前必讀:FE CLAUDE.md + BE docs/claude/frontend-overview.md。先 Read src/components/grc/ssp-docx-import-v2/SspDocxImportPage.vue(結構參考)、src/composables/useSspDocxDraft.jssrc/views/project/RoundApAuthoringView.vue(入口 + 現有行程卡欄位)。

Task D1: api 常數 + service

Files:

  • Modify: src/config/api/api.js
  • Create: src/service/ApDocxImportService.js

Task D2: draft composable

Files:

  • Create: src/composables/useApDocxDraft.js

Task D3: dialog wizard

Files:

  • Create: src/components/grc/ap-docx-import/ApDocxImportDialog.vue
    • 上:解析摘要(adapter_version + 章節 chips)+ 提示「將新增 N 筆行程到現有 M 筆之後」
    • 中:行程預填表單(標題/日期/方法 MultiSelect/每方法查核指引/備註,全可改;控制項 + 受評對象兩欄就地補勾,選項母體同 authoring 頁)
    • 下:稽核人員配對(party_suggestion → 下拉選既有 party 或「建立新的」)+ 參考資訊區(依據/目的全文,checkbox「附進備註」預設勾選)
    • Steps:active-step(PrimeVue 3.53 quirk);MultiSelect / SelectButton quirk 注意;draft 存 localStorage

Task D4: 入口按鈕

Files:

  • Modify: src/views/project/RoundApAuthoringView.vue

§8

Phase E — 測試計畫 + e2e

Task E1: test-plan.md

Task E2: e2e(在 compliance-manager-test repo)


§9

完成的定義(收尾前 self-check,然後停下等 user)

§10

驗收基準(design §5,逐項對照)

上傳亞航樣本後 preview 應解析出:標題、稽核日期 2026-06-02、稽核單位「鈊安資安顧問」、方法 INTERVIEW/EXAMINE/TEST + 每方法查核指引、範圍敘述進備註、依據+目的作為參考資訊(checkbox 預設附進備註)。confirm 後行程 append、受評控制項/受評對象 tab 推導回填、稽核人員出現在 parties。