# Stage 1：多 AP 基礎建設 — 設計規格

> 日期：2026-03-24
> 狀態：Approved

## 目標

讓現有 GRC API 支援「一個專案 = 多個 AP（Assessment Plan）」，每個 AP 代表一輪稽核。
為 Phase 3-4（外部稽核、矯正）打下基礎。

## 前置條件

- DB schema 不需變更：`project_assessment_plan_mapping` 已支援一個 project 對應多筆 AP
- 現有 Phase 1-2 功能（job CRUD、evidence、comment、review、participant 等）不受影響

---

## 1. 新增 API：AP 列表

### 端點

```
GET /api/1.0/grc/project/<project_uid>/assessment-plans/menu
```

### DDD 各層

| 層 | 檔案 | 新增/修改 |
|----|------|----------|
| Route | `api/grc/routes/assessment_plan_route.py` | 新增 |
| Serializer | `api/grc/serializers/assessment_plan.py` | 新增 |
| App Service | 複用 `ProjectService` 新增方法 | 修改 |
| Repo | `infra/grc/repository/grc_project_repo_impl.py` 新增方法 | 修改 |
| DTO | `app/grc/dto/assessment_plan_dto.py` | 新增 |
| Entity | `domain/grc/entities/grc_assessment_plan_entity.py` | 新增 |
| Blueprint | `api/grc/__init__.py` 註冊新 route | 修改 |

### Response 格式

```json
{
  "code": 1,
  "data": [
    {
      "uid": "string",
      "name": "string",
      "round_number": 1,
      "status": "preparing | in_progress | auditing | remediation | closed",
      "created_at": "2025-03-15T00:00:00"
    }
  ]
}
```

### Repo 查詢邏輯

```python
session.query(OscalAssessmentPlan)
    .join(ProjectAssessmentPlanMapping)
    .join(Project)
    .filter(Project.uid == project_uid)
    .order_by(OscalAssessmentPlan.created_at.desc())
    .all()
```

`round_number` 由查詢結果的順序推導（按 created_at 排序後的序號），不需要額外欄位。

---

## 2. 調整現有 Route — URL 加 `ap_uid`

### 變更規則

所有查詢 AP 控制項結構的 route，URL 從：
```
/grc/project/<project_uid>/...
```
改為：
```
/grc/project/<project_uid>/ap/<ap_uid>/...
```

### 需調整的 Route Class 清單

| 檔案 | Resource Class | 現有 URL | 新 URL |
|------|---------------|---------|--------|
| `control_group_route.py` | `ProjectControlGroupListResource` | `POST .../control-groups/list` | `POST .../ap/<ap_uid>/control-groups/list` |
| `control_group_route.py` | `ProjectControlGroupDetailResource` | `GET .../control-group/<gid>` | `GET .../ap/<ap_uid>/control-group/<gid>` |
| `control_route.py` | `ProjectControlListResource` | `POST .../control-group/<gid>/controls/list` | `POST .../ap/<ap_uid>/control-group/<gid>/controls/list` |
| `control_route.py` | `ProjectControlDetailResource` | `GET .../control-group/<gid>/control/<cid>` | `GET .../ap/<ap_uid>/control-group/<gid>/control/<cid>` |
| `control_route.py` | `ProjectControlWithAoDetailResource` | `GET .../control-group/<gid>/control/<cid>/detail` | `GET .../ap/<ap_uid>/control-group/<gid>/control/<cid>/detail` |
| `assessment_object_route.py` | `ProjectAOListResource` | `POST .../assessment-objects/list` | `POST .../ap/<ap_uid>/.../assessment-objects/list` |
| `job_route.py` | `ProjectJobListResource` | `POST .../assessment-object/<ao>/jobs/list` | `POST .../ap/<ap_uid>/.../assessment-object/<ao>/jobs/list` |
| `job_route.py` | `ProjectJobCreateResource` | `POST .../assessment-object/<ao>/jobs` | `POST .../ap/<ap_uid>/assessment-object/<ao>/jobs` |
| `task_setup_route.py` | `ProjectTaskSetupTreeResource` | `GET .../task-setup/tree` | `GET .../ap/<ap_uid>/task-setup/tree` |
| `review_route.py` | `ControlReviewResource` | `POST/DELETE .../control/<cid>/review` | `POST/DELETE .../ap/<ap_uid>/control/<cid>/review` |
| `review_route.py` | `AoReviewResource` | `POST/DELETE .../control/<cid>/ao/<ao>/review` | `POST/DELETE .../ap/<ap_uid>/control/<cid>/ao/<ao>/review` |

### Route 層改動模式

每個 Resource class 的改動一致：

1. URL 加 `ap/<ap_uid>` 路徑段
2. handler method 簽名加 `ap_uid: str` 參數
3. 傳遞 `ap_uid` 到 service 層

```python
# 舊
class ProjectControlGroupListResource(MethodResource):
    def post(self, project_uid: str, ...):
        result = service.list_control_groups(project_uid=project_uid, ...)

# 新
class ProjectControlGroupListResource(MethodResource):
    def post(self, project_uid: str, ap_uid: str, ...):
        result = service.list_control_groups(ap_uid=ap_uid, ...)
```

### 不改的 Route

| Route | 原因 |
|-------|------|
| `project_route.py` 全部 | 專案層級，不涉及 AP 結構 |
| `job_route.py` — Detail/Update/Delete/MyJobs | by job_uid 直查 |
| `job_comment_route.py` | by job_execution_uid |
| `dashboard_route.py` | 後端自動取最新 AP |

---

## 3. 調整 Service 層

### 改動模式

所有受影響的 service method 簽名：`project_uid` 改為 `ap_uid`。

```python
# 舊
def list_control_groups(self, user_id, is_admin, project_uid, pager, sorts, **filters):

# 新
def list_control_groups(self, user_id, is_admin, ap_uid, pager, sorts, **filters):
```

### 受影響的 Service 清單

| Service | 方法 |
|---------|------|
| `ControlGroupService` | `list_control_groups()`, `get_control_group()` |
| `ControlService` | `list_controls()`, `get_control()`, `get_control_with_ao_detail()` |
| `AssessmentObjectService` | `list_assessment_objects()` |
| `JobService` | `list_jobs()`, `create_job()` |
| `TaskSetupService` | `get_task_setup_tree()` |
| `ReviewService` | `mark_control()`, `unmark_control()`, `mark_ao()`, `unmark_ao()` |
| `DashboardService` | `get_dashboard_summary()` — 內部改為取最新 AP |

---

## 4. 調整 Repo 層

### 核心改動

所有 repo 的查詢入口從：

```python
# 舊：從 project_uid 推導 ap_id（假設 1:1）
mapping = session.query(ProjectAssessmentPlanMapping) \
    .join(Project) \
    .filter(Project.uid == project_uid) \
    .first()
ap_id = mapping.assessment_plan_id
```

改為：

```python
# 新：直接用 ap_uid 查
ap = session.query(OscalAssessmentPlan) \
    .filter(OscalAssessmentPlan.uid == ap_uid) \
    .first()
if not ap:
    raise NotFound(GrcErrorCode.GRC_AP_NOT_FOUND)
ap_id = ap.id
```

### 受影響的 Repo 清單

| Repo | 需改的方法 |
|------|----------|
| `grc_control_group_repo_impl.py` | `list_control_groups()`, `get_control_group_by_uid()` |
| `grc_control_repo_impl.py` | `list_controls()`, `get_control_by_uid()`, `get_control_with_ao_detail()` |
| `grc_assessment_object_repo_impl.py` | `list_assessment_objects()` |
| `grc_job_repo_impl.py` | `list_jobs()`, `create_job()` |
| `grc_task_setup_repo_impl.py` | `get_task_setup_tree()` |
| `grc_review_repo_impl.py` | `add_review()`, `remove_review()` |
| `grc_dashboard_repo_impl.py` | `get_dashboard_summary()` |

### Dashboard 特殊處理

Dashboard 不接收 `ap_uid`，內部改為取每個 project 最新的 AP：

```python
# 取最新 AP（created_at 最大的那筆）
latest_mapping = session.query(ProjectAssessmentPlanMapping) \
    .filter(ProjectAssessmentPlanMapping.project_id == project_id) \
    .join(OscalAssessmentPlan) \
    .order_by(OscalAssessmentPlan.created_at.desc()) \
    .first()
```

### 需新增的 Error Code

```python
# common/code/grc_error_code.py
GRC_AP_NOT_FOUND = ("稽核計畫不存在", "GRC_404015")
```

---

## 5. 向下相容

### 策略

`ap_uid` 為 URL path 必填參數，**不做 fallback**。

理由：
- URL path parameter 不適合做 optional（Flask routing 會衝突）
- 前端需要明確選擇 AP，不應有隱式行為
- 現有專案只有一個 AP，前端呼叫 AP menu 後取唯一那筆即可
- Dashboard 是唯一例外，後端自動取最新

### 遷移期

舊 URL 保留一段時間（不刪除），但內部一律 fallback 到最新 AP，並 log warning。
前端完成遷移後移除舊 URL。

---

## 6. 不改的部分

| 類別 | 說明 |
|------|------|
| DB schema | `project_assessment_plan_mapping` 已支援多筆，不需 migration |
| 專案 CRUD | `project_route.py`、`ProjectService`、`grc_project_repo_impl` 不動 |
| My Jobs | 透過 workflow_execution_uid 直查，不涉及 AP 選擇 |
| Job Detail/Update/Delete | by job_uid 直查 |
| Job Comment | by job_execution_uid |
| Job Evidence | by job_execution_uid |
| SSP 編輯 | by ssp_uid |
| Participant CRUD | by project_id |
| DI Container | 不需要調整（service 簽名變更不影響 DI） |

---

## 7. 檔案變更清單

### 新增檔案

| 檔案 | 用途 |
|------|------|
| `api/grc/routes/assessment_plan_route.py` | AP 列表 route |
| `api/grc/serializers/assessment_plan.py` | AP response schema |
| `app/grc/dto/assessment_plan_dto.py` | AP DTO |
| `domain/grc/entities/grc_assessment_plan_entity.py` | AP entity |

### 修改檔案

| 檔案 | 改動 |
|------|------|
| `api/grc/__init__.py` | 註冊 AP route |
| `api/grc/routes/control_group_route.py` | URL + handler 簽名 |
| `api/grc/routes/control_route.py` | URL + handler 簽名 |
| `api/grc/routes/assessment_object_route.py` | URL + handler 簽名 |
| `api/grc/routes/job_route.py` | URL + handler 簽名（list + create） |
| `api/grc/routes/task_setup_route.py` | URL + handler 簽名 |
| `api/grc/routes/review_route.py` | URL + handler 簽名 |
| `app/grc/service/control_group_service.py` | method 簽名 |
| `app/grc/service/control_service.py` | method 簽名 |
| `app/grc/service/assessment_object_service.py` | method 簽名 |
| `app/grc/service/job_service.py` | method 簽名 |
| `app/grc/service/task_setup_service.py` | method 簽名 |
| `app/grc/service/review_service.py` | method 簽名 |
| `app/grc/service/dashboard_service.py` | 內部取最新 AP |
| `infra/grc/repository/grc_control_group_repo_impl.py` | 查詢入口 |
| `infra/grc/repository/grc_control_repo_impl.py` | 查詢入口 |
| `infra/grc/repository/grc_assessment_object_repo_impl.py` | 查詢入口 |
| `infra/grc/repository/grc_job_repo_impl.py` | 查詢入口 |
| `infra/grc/repository/grc_task_setup_repo_impl.py` | 查詢入口 |
| `infra/grc/repository/grc_review_repo_impl.py` | 查詢入口 |
| `infra/grc/repository/grc_dashboard_repo_impl.py` | 查詢入口 |
| `domain/grc/service/grc_control_group_domain_service.py` | method 簽名 |
| `domain/grc/service/grc_control_domain_service.py` | method 簽名 |
| `domain/grc/service/grc_assessment_object_domain_service.py` | method 簽名 |
| `domain/grc/service/grc_job_domain_service.py` | method 簽名 |
| `domain/grc/service/grc_task_setup_domain_service.py` | method 簽名 |
| `domain/grc/service/grc_review_domain_service.py` | method 簽名 |
| `domain/grc/service/grc_dashboard_domain_service.py` | method 簽名 |
| `common/code/grc_error_code.py` | 新增 GRC_AP_NOT_FOUND |
