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)。
| 事實 | 值 | 影響 |
|---|---|---|
| 控制 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.ix → PE.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.vue:judgedCount=met+not_met(L128)、verdictOptions 三態(L112-115)、stats 三桶(L58)、tags(L633-635) |
Phase A 全加 na |
| parse job 表樣板 | oscal.ap_docx_parse_jobs(TenantScopedMixinModel);org_unit_id 曾漏補第二支 migration |
FR-044 建表 SQL 一次含 org_unit_id |
| 檔案大小上限 | _EXCEL_MAX_SIZE = 10MB(ssp_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 層。
jedi_oscal_v2/common/enum/oscal_enums.py — FindingState 加 NOT_APPLICABLEjedi_oscal_v2/domain/service/ar/ar_finding_matrix_service.py — token 對照 + stats na 桶app/grc/service/import_adapter/__init__.pyapp/grc/service/import_adapter/method_keywords.py — _METHOD_KEYWORDS + detect_methods()(自 airasia adapter 上收)app/grc/service/import_adapter/registry_base.py — UnsupportedFormat + RegistryBase(loader 可注入)app/grc/service/ap_report_parser/registry.py / airasia_cmmc_l1_v1.py — 改 import 共用層,行為不變infra/grc/model/ar_xlsx_parse_job.pyinfra/grc/mapper/ar_xlsx_parse_job_mapper.pyinfra/grc/repository/ar_xlsx_parse_job_repo_impl.pydomain/grc/entities/ar_xlsx_parse_job_entity.pydomain/grc/repository/i_ar_xlsx_parse_job_repo.pydomain/grc/service/ar_xlsx_parse_job_domain_service.pyscripts/sql/2026-07-03-ar-xlsx-parse-jobs.sql(含 org_unit_id)app/grc/service/ar_report_parser/__init__.pyapp/grc/service/ar_report_parser/base.py — ParsedArRecord / ParsedArRow dataclass + ArReportParserAdapterapp/grc/service/ar_report_parser/registry.py — openpyxl loader instance of RegistryBaseapp/grc/service/ar_report_parser/airasia_cmmc_l1_ar_v1.py — 亞航 adapterapp/grc/service/ar_framework_profile/__init__.pyapp/grc/service/ar_framework_profile/cmmc_l1.py — AG→[NIST] 對照 + verdict 詞彙 + AO 對齊策略app/grc/service/ar_import_app_service.py — 主流程(parse/preview/confirm/discard)app/grc/service/ar_import/ao_alignment.py — AG 區塊 → NIST 控制 AO parts 攤平對齊app/grc/service/ar_import/evidence_matcher.py — 佐證分級配對app/grc/service/ar_import/aggregation.py — 逐 NIST 控制聚合四規則app/grc/service/assessment_result_app_service.py — create_observation 加 collected、judge_finding 綁多 observation、提 public gate helpercommon/code/grc_error_code.py — 新 error codesapi/project/routes/ar_import_route.pyapi/project/serializers/ar_import.pyapi/project/__init__.py — register routesdi_containers/grc/grc_containers.py — wiringsrc/components/grc/ar-import/ArImportDialog.vue(mirror ap-docx-import/ApDocxImportDialog.vue)src/views/project/RoundAuditReviewView.vue — 入口按鈕 + verdict 第四態 + stats na 桶 + judgedCountsrc/config/api/api.js — AR import 端點常數verdict_not_applicable、stat_not_applicable、匯入 wizard 文案)test/test_fr044_*.py(adapter / aggregation / evidence / app service / package regression)目標:
not_applicable端到端可用,既有測試全綠(enum 加值的 regression)。此 Phase 不碰匯入,先讓「N/A」在系統站得住。
Files:
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(不自己跑、不自己起服務)。
FindingState 加 NOT_APPLICABLEFiles:
jedi_oscal_v2/common/enum/oscal_enums.py:31-37jedi-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"ArFindingMatrixService token 對照 + stats na 桶Files:
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 明示)
finalize_assessment na 桶不擋 finalize(驗證,多半零改)Files:
app/grc/service/assessment_result_app_service.py:398-405, 758-761test/test_fr044_finalize_na.py# 用既有 AR service test 樣式(記得 logger patch fixture,見 CLAUDE.md)
# 建 matrix → upsert 部分 finding 為 not_applicable → finalize 不 raise GRC_VERDICT_INCOMPLETERun: pytest test/test_fr044_finalize_na.py -v Expected: PASS。若 FAIL 表示 get_findings 的 stats["controls_with_not_met"] 或 finalize 有硬編 met/not_met/pending 桶漏 na — 補上再測。
Files:
src/views/project/RoundAuditReviewView.vue:58, 112-115, 128, 335-338, 633-635lang.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 桶漣漪修正"ar_xlsx_parse_jobs 表 + 持久層(mirror ap_docx_parse_job)目標:解析工作記錄表可建可讀可軟刪。建表 SQL 一次含 org_unit_id(避開 FR-043 漏補的前例)。
Files:
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.sqlExpected: CREATE TABLE … INSERT 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)"Files:(逐檔 mirror ap_docx_parse_job,把 Ap→Ar、docx→xlsx、source_type 預設 'ar_round')
domain/grc/entities/ar_xlsx_parse_job_entity.py(ArXlsxParseJobEntity + ArXlsxParseJobQueryEntity,加 org_unit_id 欄位)domain/grc/repository/i_ar_xlsx_parse_job_repo.pyinfra/grc/model/ar_xlsx_parse_job.py(BaseModel, TenantScopedMixinModel、__tablename__='ar_xlsx_parse_jobs'、schema oscal)infra/grc/mapper/ar_xlsx_parse_job_mapper.pyinfra/grc/repository/ar_xlsx_parse_job_repo_impl.pydomain/grc/service/ar_xlsx_parse_job_domain_service.py(create 的 source_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)"目標:把 FR-043 的 registry base +
_METHOD_KEYWORDS上收共用;建 AR xlsx adapter。FR-043 AP docx 匯入測試全綠 = regression 門檻。
_METHOD_KEYWORDS + detect_methods 到共用層Files:
app/grc/service/import_adapter/__init__.pyapp/grc/service/import_adapter/method_keywords.pyapp/grc/service/ap_report_parser/airasia_cmmc_l1_v1.py(改 import 共用 _METHOD_KEYWORDS)test/test_fr044_method_keywords.pydef 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 行為不變)"Files:
app/grc/service/import_adapter/registry_base.pyapp/grc/service/ap_report_parser/registry.py(改繼承共用 base,docx loader)test/test_fr044_registry_base.pyclass 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 行為不變)"Files:
app/grc/service/ar_report_parser/base.py — ParsedArRow(row_no, control_ref, objective_seq, objective_text, verdict_raw, method_text, evidence_names[])+ ParsedArRecord(adapter_version, framework, meta{project_name, verification_type, headcount, audit_date}, rows[])+ ArReportParserAdapter(ABC)app/grc/service/ar_report_parser/registry.py(RegistryBase(adapters=[AirAsiaCmmcL1ArV1Adapter()], loader=openpyxl 讀取))app/grc/service/ar_report_parser/airasia_cmmc_l1_ar_v1.pytest/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+ 逐列成 ParsedArRow;control_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 解析)"Files:
app/grc/service/ar_framework_profile/__init__.pyapp/grc/service/ar_framework_profile/cmmc_l1.pytest/test_fr044_framework_profile.pyFRAMEWORK = "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" # 空值 / 無法辨識Files:
app/grc/service/ar_import/ao_alignment.pytest/test_fr044_ao_alignment.pydef 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 列。derive_ao_pairs(caller 傳入解析好的 map,本函式純切分不碰 DB)。Files:
app/grc/service/ar_import/evidence_matcher.pytest/test_fr044_evidence_matcher.pydef 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"] == []l/I/1、0/O 常見打字錯的寬鬆比對)。job_evidences(description + upload_files.file_name)+ surveys,去重同 FE obsEvidenceOptions。caller 組 pool,matcher 純比對。Files:
app/grc/service/ar_import/aggregation.pytest/test_fr044_aggregation.pyimport 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) == expecteddef 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 混AssessmentResultAppService(collected / 多 observation / public gate)Files:
app/grc/service/assessment_result_app_service.pytest/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 含兩者(去重、累加不蓋)。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"Files:
common/code/grc_error_code.pyGRC_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")ArImportAppService 主流程(parse / preview / confirm / discard)Files:
app/grc/service/ar_import_app_service.pytest/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}]}]} ] }
]
}
PE.L1-3.10.3/4/5 各自出現且 aggregated_verdict=met、observations 數 = 2/1/3;degraded_blocks 空;overwrite_count 依既有 findings 計。GRC_AR_XLSX_FRAMEWORK_MISMATCH。Files:
api/project/serializers/ar_import.py(Parse / Preview / Confirm schema,mirror ap_docx_import.py;Confirm schema 容忍 FE 回送完整 controls,Meta.unknown=EXCLUDE)api/project/routes/ar_import_route.py(Upload / Get+Delete / Confirm,mirror ap_docx_import_route.py)api/project/__init__.py(register,URL 見 design §3)di_containers/grc/grc_containers.py(ar_xlsx_parse_job_repo/domain_service、ar_report_parser_registry、ar_import_app_service;注入 assessment_result_app_service + ssp_control_implementation_service 供佐證 pool + 控制樹)POST /audit-round/<round_uid>/ar-imports/parseGET|DELETE /ar-import/<parse_uid>POST /ar-import/<parse_uid>/confirmArImportDialog.vue + 入口按鈕Files:
src/components/grc/ar-import/ArImportDialog.vue(mirror ap-docx-import/ApDocxImportDialog.vue 三步 wizard)src/views/project/RoundAuditReviewView.vue(header 入口按鈕)src/config/api/api.js(AR_IMPORT_PARSE/AR_IMPORT 常數)AR_IMPORT_PARSE: getUrl('/audit-round'), // + /:roundUid/ar-imports/parse POST multipart
AR_IMPORT: getUrl('/ar-import'), // GET /:uid | POST /:uid/confirm | DELETE /:uidoverwrite_count 覆寫警示 / degraded_blocks 降級清單 + 來源 AG id 標註);draft 存 localStorage(key by parse_uid)。上傳樣本 xlsx 後:
PE.L1-b.1.ix 拆成 PE.L1-3.10.3/4/5 三組,各標來源 AG id)全部聚合為 met(樣本 59 列全 MET);59 筆 observation 預覽(各帶 AO 原文+方法+判定尾註);佐證高信心自動勾選(模糊帶徽章、無配對灰列);無風險建立。finalize_assessment 可正常推進(不被 pending / risk-no-finding 擋)。PE.L1-b.1.ix 拆分後只有被判 not_met 的那條 NIST 控制建風險)+中風險自動建立+link_risk_findings 已連+重複匯入 risk 防重+na 不產 POA&M。與 design §5 的差異說明:design 寫「15 個控制項」,實際系統粒度為 17 個 NIST finding(
PE.L1-b.1.ix一對多)。preview 以 NIST 分組並標註來源 AG,confirm 寫 17 筆。此為 Option A(user 2026-07-03 拍板)。
@transaction 只在 app service public method;repo lazy session;route 不碰 DB;權限 app service 層 fail-closed。git add <檔名>、禁 -am;push 等 user;不切 branch。cmmgr、-p 25432、--single-transaction -v ON_ERROR_STOP=1、先套 DEV、收尾補 STG/POC。