FR-044 稽核紀錄 xlsx 匯入 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 在稽核執行頁上傳顧問的逐條檢查紀錄 xlsx,系統解析成控制層判定+逐 AO 觀察+佐證連結,預覽修改後批次寫入本輪 AR。

Architecture: Mirror FR-043 的三段式匯入管線(parse → preview → confirm),寫入全走 AssessmentResultAppService 既有方法(judge_finding / create_observation / add_risk / link_risk_findings),單一 @transaction 全成全敗。xlsx 是 AO 粒度(59 列),系統是控制層粒度(NIST 17 條)→ 透過 framework profile 對照表(AG 15 → NIST 17,一對多) 做「AO 判定 → 控制層判定」聚合。FR-043 的 adapter registry 與 _METHOD_KEYWORDS 上收為共用 import_adapter/ 基建,AP docx 與 AR xlsx 各為一個 instance。

Tech Stack: Flask-RESTful + marshmallow、DDD(domain/grc + infra/grc)、dependency-injector、SQLAlchemy + PostgreSQL RLS、openpyxl(xlsx 讀取)、jedi_oscal_v2(FindingState / AR domain services)、Vue 3 + PrimeVue(FE wizard)。


§1

Pre-flight 已驗證事實(2026-07-03,寫 code 前的地基)

事實 影響
控制 id 對照是一對多 xlsx 15 個 AG id(AC.L1-b.1.i)→ catalog 17 個 NIST id(AC.L1-3.1.1)。唯一非 1:1:PE.L1-b.1.ixPE.L1-3.10.3+3.10.4+3.10.5 對照表用 AG → list[NIST];聚合跑在 NIST finding(17 筆);PE.L1-b.1.ix 6 列照順序拆 2/1/3
AO 數字完美對齊 xlsx AO 數 6/2/6/5/3/3/2/4/6/8/2/6/2/1/3 ↔︎ catalog assessment-objective part 數逐條相等(含 PE 2+1+3=6) derive_ao_pairs 直接可用;「xlsx 列數 = 系統 AO 數」不合者整 AG 區塊降級人工核對
finding 粒度=控制層 assessment_findings.target_id = NIST control_id(實查 ar_result 476 → IA.L1-3.5.1),target_type='objective-id' 匯入 = 逐 NIST 控制 judge_finding(聚合判定)+ 逐 AO create_observation
create_observation 硬寫 now assessment_result_app_service.py:518 collected=datetime.now(utc) 需加 optional collected 參數(Phase D)
judge_finding 只綁單一 observation observation_uid(單數)累加進 related_observations 需擴充可綁多筆(Phase D,一控制多 AO 觀察)
_check_auditor 為私有 assessment_result_app_service.py:111 提為 public gate helper 供 import 復用(Phase D)
FindingState 三態 met / not_met / pending(無 not_applicable) Phase A 加 NOT_APPLICABLE
ArFindingMatrixService _STATE_TO_TOKEN = satisfied/not-satisfied/pending;list_findings stats = met/not_met/pending/total Phase A 加 'not-applicable' token + na stats 桶
finalize_assessment 守門 stats["pending"]>0→GRC_VERDICT_INCOMPLETE;每 risk 至少連一 finding→GRC_RISK_NO_FINDING_LINKED;POA&M 掃 not-satisfied na 用新 token 天然不產 POA&M;stats na 桶加好後 pending 守門自動正確
FE 現況 RoundAuditReviewView.vuejudgedCount=met+not_met(L128)、verdictOptions 三態(L112-115)、stats 三桶(L58)、tags(L633-635) Phase A 全加 na
parse job 表樣板 oscal.ap_docx_parse_jobsTenantScopedMixinModel);org_unit_id 曾漏補第二支 migration FR-044 建表 SQL 一次含 org_unit_id
檔案大小上限 _EXCEL_MAX_SIZE = 10MBssp_excel_import_app_service.py:47 AR xlsx 沿用 10MB
error code 目前 max 400091 / 403060 / 404046 / 409033 / 412042 FR-044 從各 band 下一個號起(見 Phase 各 task)

設計決策(Option A,user 2026-07-03 拍板):一對多控制照順序拆分;preview 依 NIST 控制(17 組) 分組、每組標註來源 AG index;聚合、判定、風險都跑在 NIST finding 層。


§2

File Structure

套件(jedi_oscal_v2,dev 走 path dependency,不 commit pyproject path 改動)

  • Modify: jedi_oscal_v2/common/enum/oscal_enums.pyFindingStateNOT_APPLICABLE
  • Modify: jedi_oscal_v2/domain/service/ar/ar_finding_matrix_service.py — token 對照 + stats na 桶

BE 共用 import 基建(FR-043 泛化,本 FR 完成)

  • Create: app/grc/service/import_adapter/__init__.py
  • Create: app/grc/service/import_adapter/method_keywords.py_METHOD_KEYWORDS + detect_methods()(自 airasia adapter 上收)
  • Create: app/grc/service/import_adapter/registry_base.pyUnsupportedFormat + RegistryBase(loader 可注入)
  • Modify: app/grc/service/ap_report_parser/registry.py / airasia_cmmc_l1_v1.py — 改 import 共用層,行為不變

BE parse job 持久層(mirror ap_docx_parse_job → ar_xlsx_parse_job)

  • Create: infra/grc/model/ar_xlsx_parse_job.py
  • Create: infra/grc/mapper/ar_xlsx_parse_job_mapper.py
  • Create: infra/grc/repository/ar_xlsx_parse_job_repo_impl.py
  • Create: domain/grc/entities/ar_xlsx_parse_job_entity.py
  • Create: domain/grc/repository/i_ar_xlsx_parse_job_repo.py
  • Create: domain/grc/service/ar_xlsx_parse_job_domain_service.py
  • Create: scripts/sql/2026-07-03-ar-xlsx-parse-jobs.sql含 org_unit_id

BE framework profile + adapter

  • Create: app/grc/service/ar_report_parser/__init__.py
  • Create: app/grc/service/ar_report_parser/base.pyParsedArRecord / ParsedArRow dataclass + ArReportParserAdapter
  • Create: app/grc/service/ar_report_parser/registry.py — openpyxl loader instance of RegistryBase
  • Create: app/grc/service/ar_report_parser/airasia_cmmc_l1_ar_v1.py — 亞航 adapter
  • Create: app/grc/service/ar_framework_profile/__init__.py
  • Create: app/grc/service/ar_framework_profile/cmmc_l1.py — AG→[NIST] 對照 + verdict 詞彙 + AO 對齊策略

BE 匯入 app service + 對齊/配對

  • Create: app/grc/service/ar_import_app_service.py — 主流程(parse/preview/confirm/discard)
  • Create: app/grc/service/ar_import/ao_alignment.py — AG 區塊 → NIST 控制 AO parts 攤平對齊
  • Create: app/grc/service/ar_import/evidence_matcher.py — 佐證分級配對
  • Create: app/grc/service/ar_import/aggregation.py — 逐 NIST 控制聚合四規則
  • Modify: app/grc/service/assessment_result_app_service.pycreate_observationcollectedjudge_finding 綁多 observation、提 public gate helper
  • Modify: common/code/grc_error_code.py — 新 error codes

BE route + serializer + DI

  • Create: api/project/routes/ar_import_route.py
  • Create: api/project/serializers/ar_import.py
  • Modify: api/project/__init__.py — register routes
  • Modify: di_containers/grc/grc_containers.py — wiring

FE

  • Create: src/components/grc/ar-import/ArImportDialog.vue(mirror ap-docx-import/ApDocxImportDialog.vue
  • Modify: src/views/project/RoundAuditReviewView.vue — 入口按鈕 + verdict 第四態 + stats na 桶 + judgedCount
  • Modify: src/config/api/api.js — AR import 端點常數
  • Modify: i18n(verdict_not_applicablestat_not_applicable、匯入 wizard 文案)

測試(BE 在主專案 test/;E2E 在 compliance-manager-test)

  • Create: test/test_fr044_*.py(adapter / aggregation / evidence / app service / package regression)

§3

Phase A — 套件三件事 + BE/FE na 第四態

目標:not_applicable 端到端可用,既有測試全綠(enum 加值的 regression)。此 Phase 不碰匯入,先讓「N/A」在系統站得住。

Task A1: jedi_oscal_v2 接上 path dependency(dev-only)

Files:

  • Modify: pyproject.toml(主專案,取消註解 jedi_oscal_v2 的 path 形式;此改動不 commit

Run: python -c "import jedi_oscal_v2, os; print(os.path.realpath(jedi_oscal_v2.__file__))" 判讀:若 realpath 已指向 ~/Projects/Jedicogy/.../jedi-oscal-v2/(symlink/editable)→ 改套件源碼重啟即生效,跳過 A1 的 pyproject 改動;若指向 site-packages 的 pin 版本 → 執行 Step 2。

[tool.poetry.dependencies]jedi-oscal-v2 那行改成本地 path(參照同檔其他 path = "..." 註解樣式),然後請 user 跑 poetry update jedi-oscal-v2不自己跑、不自己起服務)。

Task A2: FindingStateNOT_APPLICABLE

Files:

  • Modify: jedi_oscal_v2/common/enum/oscal_enums.py:31-37
  • Test: jedi-oscal-v2/tests/ar/test_finding_matrix.py(既有)
def test_not_applicable_state_and_token():
    from jedi_oscal_v2.common.enum.oscal_enums import FindingState
    from jedi_oscal_v2.domain.service.ar.ar_finding_matrix_service import (
        _STATE_TO_TOKEN, _TOKEN_TO_STATE,
    )
    assert FindingState.NOT_APPLICABLE.value == "not_applicable"
    assert _STATE_TO_TOKEN["not_applicable"] == "not-applicable"
    assert _TOKEN_TO_STATE["not-applicable"] == "not_applicable"

Run: cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 && python -m pytest tests/ar/test_finding_matrix.py::test_not_applicable_state_and_token -v Expected: FAIL(AttributeError: NOT_APPLICABLE / KeyError)

class FindingState(StrEnum):
    """控制項評估結果狀態。"""
    MET = "met"
    NOT_MET = "not_met"
    NOT_APPLICABLE = "not_applicable"
    PENDING = "pending"

Task A3: ArFindingMatrixService token 對照 + stats na 桶

Files:

  • Modify: jedi_oscal_v2/domain/service/ar/ar_finding_matrix_service.py:42-48, 134-159
_STATE_TO_TOKEN: Dict[str, str] = {
    FindingState.MET.value: "satisfied",
    FindingState.NOT_MET.value: "not-satisfied",
    FindingState.NOT_APPLICABLE.value: "not-applicable",  # OSCAL 無此值,比照 pending 存 product token
    FindingState.PENDING.value: "pending",
}

_TOKEN_TO_STATE 為反向 comprehension,自動帶到)

在 stats 初始化與回傳加 FindingState.NOT_APPLICABLE.value

stats = {
    FindingState.MET.value: 0,
    FindingState.NOT_MET.value: 0,
    FindingState.NOT_APPLICABLE.value: 0,
    FindingState.PENDING.value: 0,
}
...
return {
    "findings": findings,
    "stats": {
        "met": stats[FindingState.MET.value],
        "not_met": stats[FindingState.NOT_MET.value],
        "na": stats[FindingState.NOT_APPLICABLE.value],
        "pending": stats[FindingState.PENDING.value],
        "total": len(findings),
    },
}

Run: cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 && python -m pytest tests/ -q Expected: 全綠(含新 test)。任一既有測試因 stats 多一桶失敗 → 修正該測試斷言(改成容忍 na 桶),不改行為。

cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2
git add jedi_oscal_v2/common/enum/oscal_enums.py jedi_oscal_v2/domain/service/ar/ar_finding_matrix_service.py tests/ar/test_finding_matrix.py
git commit -m "feat(FR-044): FindingState 加 NOT_APPLICABLE + matrix na token/stats 桶"

不 push、不發版、不推 Nexus — 等 user 明示)

Task A4: 主專案 finalize_assessment na 桶不擋 finalize(驗證,多半零改)

Files:

  • Modify(可能): app/grc/service/assessment_result_app_service.py:398-405, 758-761
  • Test: test/test_fr044_finalize_na.py
# 用既有 AR service test 樣式(記得 logger patch fixture,見 CLAUDE.md)
# 建 matrix → upsert 部分 finding 為 not_applicable → finalize 不 raise GRC_VERDICT_INCOMPLETE

Run: pytest test/test_fr044_finalize_na.py -v Expected: PASS。若 FAIL 表示 get_findingsstats["controls_with_not_met"] 或 finalize 有硬編 met/not_met/pending 桶漏 na — 補上再測。

Task A5: FE verdict 第四態 + stats na 桶 + judgedCount

Files:

  • Modify: src/views/project/RoundAuditReviewView.vue:58, 112-115, 128, 335-338, 633-635
  • Modify: i18n lang.round_audit.verdict_not_applicable / stat_not_applicable(zh_Hant_TW + en)
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
git add src/views/project/RoundAuditReviewView.vue src/i18n/... 
git commit -m "feat(FR-044): 稽核判定加 not_applicable 第四態 + stats na 桶"
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
git add test/test_fr044_finalize_na.py app/grc/service/assessment_result_app_service.py
git commit -m "test(FR-044): finalize 不被 not_applicable 擋 + na 桶漣漪修正"

§4

Phase B — ar_xlsx_parse_jobs 表 + 持久層(mirror ap_docx_parse_job)

目標:解析工作記錄表可建可讀可軟刪。建表 SQL 一次含 org_unit_id(避開 FR-043 漏補的前例)。

Task B1: SQL migration(含 org_unit_id、GRANT、RLS、schema_migrations)

Files:

  • Create: scripts/sql/2026-07-03-ar-xlsx-parse-jobs.sql
-- Date: 2026-07-03
-- =============================================================================
-- FR-044 稽核紀錄 xlsx 匯入 — ar_xlsx_parse_jobs 表
-- 一次含 org_unit_id(TenantScopedMixinModel 多租戶需要;避開 FR-043 漏補前例)
-- =============================================================================
CREATE TABLE oscal.ar_xlsx_parse_jobs (
    id             SERIAL PRIMARY KEY,
    uid            VARCHAR(36) NOT NULL UNIQUE,
    tenant_id      INTEGER NOT NULL,
    org_unit_id    INTEGER REFERENCES public.org_units(id),
    source_type    VARCHAR(20) NOT NULL DEFAULT 'ar_round',
    source_uid     VARCHAR(36) NOT NULL,          -- round_uid
    status         VARCHAR(20) NOT NULL DEFAULT 'pending',
    file_path      VARCHAR(255),
    file_name      VARCHAR(255),
    file_size      BIGINT,
    parsed_result  JSONB,
    error_code     VARCHAR(40),
    error_message  TEXT,
    import_summary JSONB,
    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
);
CREATE INDEX idx_ar_xlsx_parse_jobs_source ON oscal.ar_xlsx_parse_jobs(source_type, source_uid);
CREATE INDEX idx_ar_xlsx_parse_jobs_status ON oscal.ar_xlsx_parse_jobs(status, created_at);
CREATE INDEX idx_ar_xlsx_parse_jobs_tenant ON oscal.ar_xlsx_parse_jobs(tenant_id);
CREATE INDEX idx_ar_xlsx_parse_jobs_org_unit_id ON oscal.ar_xlsx_parse_jobs(org_unit_id);
ALTER TABLE oscal.ar_xlsx_parse_jobs ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_ar_xlsx_parse_jobs ON oscal.ar_xlsx_parse_jobs
    USING (tenant_id::text = current_setting('app.user_id', true)
        OR current_setting('app.allowed_tenant_paths', true) LIKE '%' || tenant_id::text || '%');
GRANT SELECT, INSERT, UPDATE, DELETE ON oscal.ar_xlsx_parse_jobs TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE oscal.ar_xlsx_parse_jobs_id_seq TO cm_app;
INSERT INTO public.schema_migrations(filename, note) VALUES
  ('2026-07-03-ar-xlsx-parse-jobs.sql', 'FR-044 / 新建 oscal.ar_xlsx_parse_jobs(AR xlsx 匯入解析工作記錄,TTL 24h)')
ON CONFLICT (filename) DO NOTHING;

Run:

set -a; source .env; set +a
PGPASSWORD='<查 .env DB_SECRET>' 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-03-ar-xlsx-parse-jobs.sql

Expected: CREATE TABLEINSERT 0 1,無 ERROR。

Run: PGPASSWORD='...' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c "\d oscal.ar_xlsx_parse_jobs" Expected: 含 org_unit_id 欄;RLS enabled。

git add scripts/sql/2026-07-03-ar-xlsx-parse-jobs.sql
git commit -m "feat(FR-044): ar_xlsx_parse_jobs 表 migration(含 org_unit_id/RLS/GRANT)"

Task B2: entity / repo interface / model / mapper / repo impl / domain service

Files:(逐檔 mirror ap_docx_parse_job,把 Ap→Ar、docx→xlsx、source_type 預設 'ar_round'

  • Create: domain/grc/entities/ar_xlsx_parse_job_entity.pyArXlsxParseJobEntity + ArXlsxParseJobQueryEntity,加 org_unit_id 欄位)
  • Create: domain/grc/repository/i_ar_xlsx_parse_job_repo.py
  • Create: infra/grc/model/ar_xlsx_parse_job.pyBaseModel, TenantScopedMixinModel__tablename__='ar_xlsx_parse_jobs'、schema oscal
  • Create: infra/grc/mapper/ar_xlsx_parse_job_mapper.py
  • Create: infra/grc/repository/ar_xlsx_parse_job_repo_impl.py
  • Create: domain/grc/service/ar_xlsx_parse_job_domain_service.pycreatesource_type="ar_round"

Files: Test: test/test_fr044_ar_xlsx_parse_job_mapper.py

def test_ar_xlsx_parse_job_mapper_roundtrip():
    from infra.grc.model.ar_xlsx_parse_job import ArXlsxParseJob
    from infra.grc.mapper.ar_xlsx_parse_job_mapper import ArXlsxParseJobMapper
    m = ArXlsxParseJob(id=1, uid="u", tenant_id=1, source_type="ar_round",
                       source_uid="r", status="pending", is_active=True)
    e = ArXlsxParseJobMapper.to_entity(m)
    assert e.uid == "u" and e.source_type == "ar_round"

Run: pytest test/test_fr044_ar_xlsx_parse_job_mapper.py -v Expected: PASS

git add domain/grc/entities/ar_xlsx_parse_job_entity.py domain/grc/repository/i_ar_xlsx_parse_job_repo.py \
        infra/grc/model/ar_xlsx_parse_job.py infra/grc/mapper/ar_xlsx_parse_job_mapper.py \
        infra/grc/repository/ar_xlsx_parse_job_repo_impl.py domain/grc/service/ar_xlsx_parse_job_domain_service.py \
        test/test_fr044_ar_xlsx_parse_job_mapper.py
git commit -m "feat(FR-044): ar_xlsx_parse_job 持久層(entity/repo/mapper/domain service)"

§5

Phase C — 共用 import 基建泛化 + 亞航 AR adapter(TDD)

目標:把 FR-043 的 registry base + _METHOD_KEYWORDS 上收共用;建 AR xlsx adapter。FR-043 AP docx 匯入測試全綠 = regression 門檻

Task C1: 上收 _METHOD_KEYWORDS + detect_methods 到共用層

Files:

  • Create: app/grc/service/import_adapter/__init__.py
  • Create: app/grc/service/import_adapter/method_keywords.py
  • Modify: app/grc/service/ap_report_parser/airasia_cmmc_l1_v1.py(改 import 共用 _METHOD_KEYWORDS
  • Test: test/test_fr044_method_keywords.py
def test_detect_methods_maps_keywords():
    from app.grc.service.import_adapter.method_keywords import detect_methods
    assert "DOCUMENT_REVIEW" in detect_methods("文件檢查:核對員工帳號申請")
    assert "COMPUTER_CONFIG_COMPARE" in detect_methods("電腦設定比對:windows 事件檢視器")
    assert detect_methods("無關文字") == []

Run: pytest test/test_fr044_method_keywords.py -v → FAIL

Run: pytest test/ -k "ap_docx or ap_report or fr043 or airasia" -q Expected: 全綠(沿用既有測試;找不到就先確認 FR-043 測試檔名並全跑一次 pytest test/ -q 存 baseline)。

git add app/grc/service/import_adapter/__init__.py app/grc/service/import_adapter/method_keywords.py \
        app/grc/service/ap_report_parser/airasia_cmmc_l1_v1.py test/test_fr044_method_keywords.py
git commit -m "refactor(FR-044): _METHOD_KEYWORDS 上收 import_adapter 共用層(FR-043 行為不變)"

Task C2: 上收 registry base(loader 可注入)

Files:

  • Create: app/grc/service/import_adapter/registry_base.py
  • Modify: app/grc/service/ap_report_parser/registry.py(改繼承共用 base,docx loader)
  • Test: test/test_fr044_registry_base.py
class UnsupportedFormat(Exception):
    def __init__(self, supported_versions):
        self.supported_versions = supported_versions
        super().__init__("no adapter matched")

class RegistryBase:
    """loader(file_path) -> doc;adapters 逐一 detect(doc)。"""
    def __init__(self, adapters, loader):
        self._adapters = adapters
        self._loader = loader
    @property
    def supported_versions(self):
        return [a.version for a in self._adapters]
    def parse_file(self, file_path):
        doc = self._loader(file_path)
        for a in self._adapters:
            if a.detect(doc):
                return a.parse(doc)
        raise UnsupportedFormat(self.supported_versions)

Run: pytest test/ -k "ap_docx or ap_report" -q → 全綠

git add app/grc/service/import_adapter/registry_base.py app/grc/service/ap_report_parser/registry.py test/test_fr044_registry_base.py
git commit -m "refactor(FR-044): registry base 上收共用層(loader 可注入;FR-043 行為不變)"

Task C3: AR 中介結構 + 亞航 xlsx adapter(TDD,樣本 fixture)

Files:

  • Create: app/grc/service/ar_report_parser/base.pyParsedArRowrow_no, control_ref, objective_seq, objective_text, verdict_raw, method_text, evidence_names[])+ ParsedArRecordadapter_version, framework, meta{project_name, verification_type, headcount, audit_date}, rows[])+ ArReportParserAdapter(ABC)
  • Create: app/grc/service/ar_report_parser/registry.pyRegistryBase(adapters=[AirAsiaCmmcL1ArV1Adapter()], loader=openpyxl 讀取)
  • Create: app/grc/service/ar_report_parser/airasia_cmmc_l1_ar_v1.py
  • Test: test/test_fr044_ar_adapter.py(樣本 xlsx 進 fixture)
def test_airasia_ar_adapter_parses_59_rows():
    from app.grc.service.ar_report_parser.airasia_cmmc_l1_ar_v1 import AirAsiaCmmcL1ArV1Adapter
    import openpyxl
    wb = openpyxl.load_workbook("test/fixtures/fr044/airasia_cmmc_l1_ar.xlsx", data_only=True)
    a = AirAsiaCmmcL1ArV1Adapter()
    assert a.detect(wb) is True
    rec = a.parse(wb)
    assert rec.framework == "cmmc_l1"
    assert len(rec.rows) == 59
    # 15 個 AG 區塊、AO 數分佈
    from collections import Counter
    c = Counter(r.control_ref for r in rec.rows)
    assert list(c.values()) == [6,2,6,5,3,3,2,4,6,8,2,6,2,1,3]
    assert rec.meta["audit_date"] == "2026-06-02"   # 2026年6月2日 → ISO
    assert all(r.verdict_raw == "MET" for r in rec.rows)
    # 「(同N)」交叉引用保留原文,解析階段不展開(交給 evidence_matcher)
    • detect:sheet 名含「檢查紀錄」且 row5 表頭含「索引號/Objectives/稽核結果」。
    • parse:row1-4 抓 meta(專案名稱 / 受驗證型態 / 受稽核人數 / 稽核日期→ISO,日期解析復用 airasia_cmmc_l1_v1 的日期 helper 或搬到 import_adapter 共用);row6+ 逐列成 ParsedArRowcontrol_ref strip 尾空白(row12 AC.L1-b.1.ii 有尾空白);objective_seq 從 Objectives 欄的 [a]/[b] 前綴解;method_text=Status 欄;evidence_names=Evidence 欄一格多名先拆(換行/逗號/頓號)。
git add app/grc/service/ar_report_parser/ test/test_fr044_ar_adapter.py test/fixtures/fr044/airasia_cmmc_l1_ar.xlsx
git commit -m "feat(FR-044): 亞航 CMMC L1 AR xlsx adapter(59 列 snapshot + meta 解析)"

§6

Phase D — framework profile + 對齊 + 配對 + 聚合 + app service(核心)

Task D1: CMMC L1 framework profile(AG→[NIST] 對照 + AO 對齊校驗)

Files:

  • Create: app/grc/service/ar_framework_profile/__init__.py
  • Create: app/grc/service/ar_framework_profile/cmmc_l1.py
  • Test: test/test_fr044_framework_profile.py
FRAMEWORK = "cmmc_l1"
# AG (檢查紀錄) → NIST (catalog) 一對多對照,pre-flight 驗證 AO 數逐條對齊
AG_TO_NIST = {
    "AC.L1-b.1.i":   ["AC.L1-3.1.1"],
    "AC.L1-b.1.ii":  ["AC.L1-3.1.2"],
    "AC.L1-b.1.iii": ["AC.L1-3.1.20"],
    "AC.L1-b.1.iv":  ["AC.L1-3.1.22"],
    "IA.L1-b.1.v":   ["IA.L1-3.5.1"],
    "IA.L1-b.1.vi":  ["IA.L1-3.5.2"],
    "MP.L1-b.1.vii": ["MP.L1-3.8.3"],
    "PE.L1-b.1.viii":["PE.L1-3.10.1"],
    "PE.L1-b.1.ix":  ["PE.L1-3.10.3", "PE.L1-3.10.4", "PE.L1-3.10.5"],
    "SC.L1-b.1.x":   ["SC.L1-3.13.1"],
    "SC.L1-b.1.xi":  ["SC.L1-3.13.5"],
    "SI.L1-b.1.xii": ["SI.L1-3.14.1"],
    "SI.L1-b.1.xiii":["SI.L1-3.14.2"],
    "SI.L1-b.1.xiv": ["SI.L1-3.14.4"],
    "SI.L1-b.1.xv":  ["SI.L1-3.14.5"],
}
def map_verdict(raw: str) -> str:
    t = (raw or "").strip().upper().replace("/", "/")
    if t in ("MET",): return "met"
    if t in ("NOT MET", "NOTMET", "NOT-MET"): return "not_met"
    if t in ("N/A", "NA", "NOT APPLICABLE"): return "not_applicable"
    return "pending"  # 空值 / 無法辨識

Task D2: AO 對齊(AG 區塊 → NIST 控制 AO parts 攤平,Option A 拆分)

Files:

  • Create: app/grc/service/ar_import/ao_alignment.py
  • Test: test/test_fr044_ao_alignment.py
def test_align_splits_one_to_many_by_order():
    # AG PE.L1-b.1.ix 6 列 → NIST [3.10.3(2AO), 3.10.4(1AO), 3.10.5(3AO)]
    # 期望:前2列→3.10.3、第3列→3.10.4、後3列→3.10.5
    from app.grc.service.ar_import.ao_alignment import align_block
    nist_ao_counts = {"PE.L1-3.10.3": 2, "PE.L1-3.10.4": 1, "PE.L1-3.10.5": 3}
    nist_order = ["PE.L1-3.10.3", "PE.L1-3.10.4", "PE.L1-3.10.5"]
    rows = list(range(6))  # 6 個 xlsx 列
    result = align_block(rows, nist_order, nist_ao_counts)
    assert result["PE.L1-3.10.3"] == [0, 1]
    assert result["PE.L1-3.10.4"] == [2]
    assert result["PE.L1-3.10.5"] == [3, 4, 5]

def test_align_count_mismatch_flags_degrade():
    from app.grc.service.ar_import.ao_alignment import align_block, AlignmentMismatch
    # xlsx 5 列 但 NIST AO 總數 6 → 降級
    import pytest
    with pytest.raises(AlignmentMismatch):
        align_block(list(range(5)), ["X"], {"X": 6})
    • 校驗 sum(nist_ao_counts) == len(xlsx_rows),不合拋 AlignmentMismatch(caller 整 AG 區塊降級、標 preview 人工核對)。
    • nist_control_ids_in_order 順序,各取該控制 AO 數個 xlsx 列。
    • NIST 控制的 AO 數與順序來源= derive_ao_pairs(caller 傳入解析好的 map,本函式純切分不碰 DB)。

Task D3: 佐證分級配對

Files:

  • Create: app/grc/service/ar_import/evidence_matcher.py
  • Test: test/test_fr044_evidence_matcher.py
def test_evidence_exact_fuzzy_none():
    from app.grc.service.ar_import.evidence_matcher import match_evidence
    pool = [{"uid":"e1","file_name":"Account_Privilege_Review_Plan.xlsx","description":""},
            {"uid":"e2","file_name":"Windows_Installer.log","description":""}]
    # 精確(去副檔名後全等)
    r1 = match_evidence(["Account_Privilege_Review_Plan"], pool)
    assert r1[0]["level"] == "exact" and r1[0]["matched"][0]["uid"] == "e1"
    # 模糊(Windowslnstaller l/I 打字錯)
    r2 = match_evidence(["Windowslnstaller"], pool)
    assert r2[0]["level"] == "fuzzy"
    # 無配對
    r3 = match_evidence(["完全不存在的檔"], pool)
    assert r3[0]["level"] == "none" and r3[0]["matched"] == []
    • 正規化:去副檔名 → 去括號註記 → 去空白標點 → lower-case(含 l/I/10/O 常見打字錯的寬鬆比對)。
    • 級別:1 精確(正規化全等)/2 模糊(token 重疊 or 編輯距離達門檻)/3 無配對。
    • 「(同N)」:回傳 marker 讓 caller 解析為第 N 列的佐證集合(本函式只標記,不跨列展開)。
    • pool = 該控制各 AO 的 job_evidencesdescription + upload_files.file_name)+ surveys,去重同 FE obsEvidenceOptionscaller 組 pool,matcher 純比對
    • 原則:寧漏配勿錯配(門檻用 fixture 調)。

Task D4: 聚合四規則(逐 NIST 控制)

Files:

  • Create: app/grc/service/ar_import/aggregation.py
  • Test: test/test_fr044_aggregation.py
import pytest
from app.grc.service.ar_import.aggregation import aggregate_verdict
@pytest.mark.parametrize("states,expected", [
    (["met","met"], "met"),
    (["met","not_met"], "not_met"),
    (["not_applicable","not_applicable"], "not_applicable"),
    (["met","not_applicable"], "met"),          # met+na 混 → met(CMMC 計分 N/A 視同 MET)
    (["met","pending"], "pending"),
])
def test_aggregate(states, expected):
    assert aggregate_verdict(states) == expected
def aggregate_verdict(states: list[str]) -> str:
    if any(s == "not_met" for s in states): return "not_met"
    if states and all(s == "not_applicable" for s in states): return "not_applicable"
    if any(s == "pending" for s in states): return "pending"
    return "met"  # 全 met 或 met+na 混

Task D5: 擴充 AssessmentResultAppService(collected / 多 observation / public gate)

Files:

  • Modify: app/grc/service/assessment_result_app_service.py
  • Test: test/test_fr044_assessment_result_extensions.py記得 logger patch fixture,見 feedback_test_logger_patch_db_handler
    • create_observation(..., collected=<dt>) → 回傳 obs.collected == 傳入值;不傳 → fallback now。
    • judge_finding(..., observation_uids=[o1,o2]) → finding.related_observations 含兩者(去重、累加不蓋)。
    • public gate helper resolve_round_for_import(round_uid, user_id):auditor/manager 過、其他 raise ForbiddenError;stage≠auditing raise(既有 assert_round_phase);無 ar_result raise 412 新碼。
def create_observation(self, round_uid, curr_user, curr_user_id, description,
                       title=None, methods=None, relevant_evidence=None,
                       subjects=None, collected=None, locale=None) -> dict:
    ...
    collected=collected or datetime.now(timezone.utc),
def resolve_round_for_import(self, round_uid: str, user_id: int):
    """Import 專用守門:auditor/manager + stage=auditing + ar_result 已建。回 round。"""
    r = self._require_round_ar_result(round_uid)          # 含 assert_round_phase auditing + 404 無 ar_result
    self._check_auditor(r.project_id, user_id)            # 既有私有邏輯,經此 public 出口復用
    return r

(design 要求「gate 提 public helper」;此法零複製復用既有 _require_round_ar_result + _check_auditor。註:_require_round_ar_result 無 ar_result 時回 404 GRC_AR_NOT_FOUND — 若要 import 專屬 412 語意,另加 resolve_round_for_import 內先檢 r.ar_result_id,缺則 raise PreconditionFailedError(GRC_AR_ROUND_NOT_AUDITING),符合 design §3「未啟動回 412」。)

git add app/grc/service/assessment_result_app_service.py test/test_fr044_assessment_result_extensions.py
git commit -m "feat(FR-044): AR service 擴充 collected/多 observation 綁定/import gate helper"

Task D6: 錯誤碼

Files:

  • Modify: common/code/grc_error_code.py
GRC_AR_XLSX_INVALID_FILE        = ("上傳檔案格式無效,請上傳 .xlsx 稽核紀錄檔",        "GRC_400092")
GRC_AR_XLSX_FILE_TOO_LARGE      = ("上傳檔案超過大小限制(10MB)",                    "GRC_400093")
GRC_AR_XLSX_FORMAT_UNSUPPORTED  = ("無法辨識的稽核紀錄格式,目前支援:{versions}",    "GRC_400094")
GRC_AR_XLSX_FRAMEWORK_MISMATCH  = ("檔案框架({file_fw})與本輪框架({round_fw})不符", "GRC_400095")
GRC_AR_XLSX_PARSE_JOB_NOT_FOUND = ("稽核紀錄解析任務不存在或已被刪除",                "GRC_404047")
GRC_AR_ROUND_NOT_AUDITING       = ("本輪尚未啟動稽核(無判定框架),無法匯入稽核紀錄", "GRC_412043")
GRC_AR_XLSX_PARSE_JOB_NOT_AWAITING = ("稽核紀錄解析任務狀態不是 awaiting_review,無法確認匯入", "GRC_412044")
GRC_AR_XLSX_PARSE_JOB_EXPIRED   = ("稽核紀錄解析任務已過期(24h)",                   "GRC_412045")

Task D7: ArImportAppService 主流程(parse / preview / confirm / discard)

Files:

  • Create: app/grc/service/ar_import_app_service.py
  • Test: test/test_fr044_ar_import_app_service.py

流程要點(mirror ap_docx_import_app_service.py):

  • upload_and_parse(file, round_uid, user_context)resolve_round_for_import 守門 → .xlsx + 10MB 驗證 → 建 parse job → tempfile + file_upload → registry.parse_file → 框架一致性檢查(adapter.framework vs 本輪 AP 框架,不符 raise GRC_AR_XLSX_FRAMEWORK_MISMATCH)→ 建 parsed_result(見下)→ 持久化。單一 @transaction
  • get_parse_result:讀 parsed_result + TTL 標記 + 佐證/覆寫警示(讀路徑)。
  • confirm_import:狀態=awaiting_review + 未過期 → 守門 → 逐 NIST 控制迴圈judge_finding(聚合判定 + 綁該控制 observations) + 逐 AO create_observation(collected + methods + relevant_evidence + description 尾註判定);not_met 控制 add_risk(severity medium) + link_risk_findings;單一 @transaction 全成全敗。
  • discard_parse:軟刪。

parsed_result 結構(持久化 JSONB):

{
  adapter_version, framework,
  meta: {project_name, verification_type, headcount, audit_date},
  overwrite_count: <本輪已有判定的 NIST 控制數(將被覆寫)>,
  degraded_blocks: [<AO 數不一致的 AG id>],
  controls: [   # 依 NIST 控制(17)分組
    { control_id(NIST), source_ag_id, source_row_range,
      aggregated_verdict, will_create_risk: bool,
      observations: [ {objective_seq, description(AO原文+方法+判定尾註),
                       methods:[...], evidence:[{level, names, matched:[{uid,file_name}]}]} ] }
  ]
}
    • parse 樣本 → parsed_result.controls 有 17 組、PE.L1-3.10.3/4/5 各自出現且 aggregated_verdict=met、observations 數 = 2/1/3;degraded_blocks 空;overwrite_count 依既有 findings 計。
    • 框架不符 → raise GRC_AR_XLSX_FRAMEWORK_MISMATCH
    • 非 .xlsx / >10MB → 400。
    • confirm 全 MET → judge_finding 呼叫 17 次(met)、create_observation 59 次、add_risk 0 次。
    • 改造樣本(某 AG 區塊有 NOT MET)→ 對應 NIST 控制 aggregated not_met、add_risk severity=medium + link_risk_findings 呼叫、重複 confirm 不重複建 risk(防重見 Step 2)。
    • stage≠auditing / 無 ar_result → 412;TTL 過期 confirm → 412;非 awaiting → 412。

§7

Phase E — route / serializer / DI / FE

Task E1: serializer + route + DI wiring

Files:

  • Create: api/project/serializers/ar_import.py(Parse / Preview / Confirm schema,mirror ap_docx_import.py;Confirm schema 容忍 FE 回送完整 controls,Meta.unknown=EXCLUDE
  • Create: api/project/routes/ar_import_route.py(Upload / Get+Delete / Confirm,mirror ap_docx_import_route.py
  • Modify: api/project/__init__.py(register,URL 見 design §3)
  • Modify: di_containers/grc/grc_containers.pyar_xlsx_parse_job_repo/domain_servicear_report_parser_registryar_import_app_service;注入 assessment_result_app_service + ssp_control_implementation_service 供佐證 pool + 控制樹)
    • POST /audit-round/<round_uid>/ar-imports/parse
    • GET|DELETE /ar-import/<parse_uid>
    • POST /ar-import/<parse_uid>/confirm

Task E2: FE ArImportDialog.vue + 入口按鈕

Files:

  • Create: src/components/grc/ar-import/ArImportDialog.vue(mirror ap-docx-import/ApDocxImportDialog.vue 三步 wizard)
  • Modify: src/views/project/RoundAuditReviewView.vue(header 入口按鈕)
  • Modify: src/config/api/api.jsAR_IMPORT_PARSE/AR_IMPORT 常數)
  • Modify: i18n(wizard 文案)
AR_IMPORT_PARSE: getUrl('/audit-round'),   // + /:roundUid/ar-imports/parse  POST multipart
AR_IMPORT:       getUrl('/ar-import'),      // GET /:uid | POST /:uid/confirm | DELETE /:uid
    • Step 1 上傳(.xlsx、10MB)→ 先開 dialog 再 spinner → parse。
    • Step 2 預覽:依 NIST 控制分組(17)、可摺疊;每 AO 列=判定 SelectButton(四態可改)+ 觀察文字(可編)+ 佐證 chips(分級徽章 exact/fuzzy/none + 下拉補勾)+ NOT MET 列「將建立中風險」徽章;頂部摘要(樣板版本 / 框架 / overwrite_count 覆寫警示 / degraded_blocks 降級清單 + 來源 AG id 標註);draft 存 localStorage(key by parse_uid)。
    • Step 3 完成:confirm → toast + 摘要(判定 N / 觀察 N / 風險 N / 佐證連結 N)→ refresh findings/stats/risks。
    • 關閉前未確認編輯二次確認。

§8

Phase F — 測試計畫 + E2E

Task F1: 測試計畫(feature-test-planner agent)

Task F2: E2E(compliance-manager-test repo)


§9

驗收基準(亞航樣本,對照 design §5 + Option A 修正)

上傳樣本 xlsx 後:

  • preview:17 個 NIST 控制分組(PE.L1-b.1.ix 拆成 PE.L1-3.10.3/4/5 三組,各標來源 AG id)全部聚合為 met(樣本 59 列全 MET);59 筆 observation 預覽(各帶 AO 原文+方法+判定尾註);佐證高信心自動勾選(模糊帶徽章、無配對灰列);無風險建立。
  • confirm 後稽核執行頁:17 控制項判定=met(左側清單本就 17 條)、observations 掛對控制項、stats 正確(met=17、na=0、pending=0)、finalize_assessment 可正常推進(不被 pending / risk-no-finding 擋)。
  • 改造樣本(改幾列 NOT MET/N/A/留空)驗:聚合四規則(含 PE.L1-b.1.ix 拆分後只有被判 not_met 的那條 NIST 控制建風險)+中風險自動建立+link_risk_findings 已連+重複匯入 risk 防重+na 不產 POA&M。

與 design §5 的差異說明:design 寫「15 個控制項」,實際系統粒度為 17 個 NIST findingPE.L1-b.1.ix 一對多)。preview 以 NIST 分組並標註來源 AG,confirm 寫 17 筆。此為 Option A(user 2026-07-03 拍板)。


§10

實作紀律(全程)

  • @transaction 只在 app service public method;repo lazy session;route 不碰 DB;權限 app service 層 fail-closed。
  • 顯式 git add <檔名>、禁 -am;push 等 user;不切 branch。
  • SQL migration 用 cmmgr-p 25432--single-transaction -v ON_ERROR_STOP=1、先套 DEV、收尾補 STG/POC。
  • 禁重複造輪子:寫任何新 method 前先 grep 既有行為(本 plan 的擴充皆為既有方法加參數,非新造)。
  • app service test 記得 logger patch fixture;jedi_oscal_v2 dev 走 path dependency、不自行發版。
  • 收尾動作(changelog / SUMMARY / Notion / memory)等 user 明示才做。