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。
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 ✅[Normal] 段落(依據 / 目的 / 稽核單位 / 稽核日期 / 稽核範圍 / 稽核方法 / 稽核結果),parser 以標題字串辨識章節,非樣式或編號。| 決策 | 選擇 | 理由 |
|---|---|---|
| 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 掛載 | GrcContainer(di_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 |
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-planner 產 test-plan.md,e2e feature/step/page 一律在此 repo。
oscal.ap_docx_parse_jobsFiles:
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.sqlExpected: CREATE TABLE … GRANT … INSERT 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 先行)。
Files:
domain/grc/entities/ap_docx_parse_job_entity.pyfrom 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_activeFiles:
infra/grc/model/ap_docx_parse_job.pyfrom 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_user由BaseModel+TenantScopedMixinModel提供。實作時先 ReadSspDocxParseJobmodel 確認 mixin 是否已含tenant_id(ssp 版沒有另宣告tenant_id)。
Files:
infra/grc/mapper/ap_docx_parse_job_mapper.pyFiles:
domain/grc/repository/i_ap_docx_parse_job_repo.pyinfra/grc/repository/ap_docx_parse_job_repo_impl.pyFiles:
domain/grc/service/ap_docx_parse_job_domain_service.pyimport 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"Files:
di_containers/grc/grc_containers.pyfrom 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,
)di_containers/grc/grc_containers.py:GrcContainer 加 upload_file_container = providers.DependenciesContainer()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。ParsedApReport / adapter baseFiles:
test/fixtures/ap_docx/airasia_cmmc_l1_sample.docx(cp 桌面樣本)app/grc/service/ap_report_parser/base.pymkdir -p test/fixtures/ap_docx
cp "/Users/chouraymond/Desktop/AirAsia實際稽核檔案/AP稽核計畫匯入測試檔案/亞航-CMMC L1內部稽核報告20260612(稿).docx" \
test/fixtures/ap_docx/airasia_cmmc_l1_sample.docxfrom 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:
"""解析成版本無關中間結構。"""Files:
app/grc/service/ap_report_parser/registry.pyapp/grc/service/ap_report_parser/__init__.pyfrom 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)Files:
test/test_ap_report_parser_airasia.pyapp/grc/service/ap_report_parser/airasia_cmmc_l1_v1.pyAUDIT_METHOD value 對照(開工前先確認):design §3 說 methods 對照
system_menuAUDIT_METHOD。程式碼未 grep 到常數,值存 DBsystem_menu。Step 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)當methodsvalue,別自創字串。若查無 → 停下回報 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_textRun: 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)。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)"Files:
common/code/grc_error_code.pyGRC_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。
Files:
app/grc/service/assessment_plan_app_service.pydef 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 兩版)"Files:
api/project/serializers/ap_docx_import.pyfrom 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()Files:
app/grc/service/ap_docx_import_app_service.pytest/test_ap_docx_import_app_service.pyimport 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_service、parser(= ApReportParserRegistry)、file_upload_service、assessment_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:
self._ap_svc.resolve_ap_and_check_auditor(ap_uid, user_context.id)(parse 需 manager/auditor + audit_planning 階段).docx(GRC_DOCX_INVALID_FILE)、size(GRC_AP_DOCX_FILE_TOO_LARGE)job = self._parse_job.create(source_uid=ap_uid, file_name=…, file_size=…, tenant_id=user_context.tenant_id, user=user_context.login_name)tempfile,mirror ssp 版;上傳 file_upload_service 取 file_uid 存 file_path)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)parsed_result(見 Step 3 shape)→ write_parsed_result → 清暫存檔 → 回 {parse_uid, status:"awaiting_review"}get_parse_result(parse_uid, user_context) @transaction:
job = self._parse_job.get_one;不存在/非 active → 404;tenant 不符且非 admin → 404self._ap_svc.resolve_ap_and_check_participant(job.source_uid, user_context.id)(preview 任一 participant)update_status(expired) + job.status="expired"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)+ deactivateconfirm_import(parse_uid, payload, user_context) @transaction:
job 404 檢查;job.status != "awaiting_review" → 412 GRC_DOCX_PARSE_JOB_NOT_AWAITING;TTL 過期 → 412 GRC_DOCX_PARSE_JOB_EXPIREDap = self._ap_svc.resolve_ap_and_check_auditor(job.source_uid, user_context.id)(雙重保險,set_tasks 內也會再擋)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。payload["tasks"] 每筆,把 resolved_party_uids 併進其 participants({"party_uuid": uid, "role_id": "assessor"},與既有去重)。_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)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 itemsgit 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 配對)"Files:
api/project/routes/ap_docx_import_route.pyapi/project/__init__.pydi_containers/grc/grc_containers.pyPOST /ap/<ap_uid>/docx-imports/parse → upload_and_parseGET /ap-docx-import/<parse_uid> → get_parse_resultDELETE /ap-docx-import/<parse_uid> → discard_parsePOST /ap-docx-import/<parse_uid>/confirm → confirm_import(load ApDocxImportConfirmRequestSchema)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"動工前必讀:FE
CLAUDE.md+ BEdocs/claude/frontend-overview.md。先 Readsrc/components/grc/ssp-docx-import-v2/SspDocxImportPage.vue(結構參考)、src/composables/useSspDocxDraft.js、src/views/project/RoundApAuthoringView.vue(入口 + 現有行程卡欄位)。
Files:
src/config/api/api.jssrc/service/ApDocxImportService.jsFiles:
src/composables/useApDocxDraft.jsFiles:
src/components/grc/ap-docx-import/ApDocxImportDialog.vueadapter_version + 章節 chips)+ 提示「將新增 N 筆行程到現有 M 筆之後」party_suggestion → 下拉選既有 party 或「建立新的」)+ 參考資訊區(依據/目的全文,checkbox「附進備註」預設勾選)Steps 用 :active-step(PrimeVue 3.53 quirk);MultiSelect / SelectButton quirk 注意;draft 存 localStorageFiles:
src/views/project/RoundApAuthoringView.vuecompliance-manager-test repo)上傳亞航樣本後 preview 應解析出:標題、稽核日期 2026-06-02、稽核單位「鈊安資安顧問」、方法 INTERVIEW/EXAMINE/TEST + 每方法查核指引、範圍敘述進備註、依據+目的作為參考資訊(checkbox 預設附進備註)。confirm 後行程 append、受評控制項/受評對象 tab 推導回填、稽核人員出現在 parties。