# P18：管理后台 — 任务引擎运营看板与参数管理

> 版本：v2.1-reviewed | 日期：2026-03-24 | 作者：AI（待评审）
> 依赖：P17（助教客户归属与任务生成引擎）、P10（租户管理后台，部分复用）
> 状态：评审通过，可进入实施

---

## 0. 文档背景与目标

### 0.1 问题来源

P17 实现了助教客户归属、任务生成、客户转移三大引擎能力，但所有数据仅存在于数据库中，运营团队无法：
- 查看客户转移日志（谁被转给了谁、为什么、三重保护检查结果）
- 审核 `pending_review` 状态的任务（转移次数超限后需人工介入）
- 按门店调整任务生成参数（召回阈值、转移保护参数等）
- 监控任务生成器的运行健康度（生成/替换/跳过/转移统计）

同时，现有 `http://localhost:5173/trigger-jobs` 定时任务页面功能较基础（仅展示 + 手动执行），需要评估是否扩展为更完整的调度监控面板。

### 0.2 本文档目标

1. 定义 admin-web 中 P17 相关的三个新页面（转移日志、待审核任务、参数管理）
2. 评估现有 trigger-jobs 页面的扩展需求
3. 明确后端 API 需求（含 Pydantic schema 定义）
4. 给出优先级排序和实施建议
5. 明确技术实现方案（前端组件结构、路由注册、权限控制）

### 0.3 技术栈概要

| 层 | 技术 |
|----|------|
| 前端 | React 19 + Vite 6 + Ant Design 5 + axios + Zustand + react-router-dom 7 |
| API 客户端 | `apps/admin-web/src/api/client.ts`，baseURL = `/api`，JWT 自动附加 + 401 自动刷新 |
| 后端 | FastAPI + Pydantic v2 + asyncpg |
| 认证 | JWT（`CurrentUser` 含 user_id / site_id / roles），`Depends(get_current_user)` |
| 响应包装 | 全局中间件自动 `{ code: 0, data: <body> }`，前端 axios 拦截器自动解包 |

---

## 1. 现有 trigger-jobs 页面评估

### 1.1 当前能力

| 功能 | 状态 |
|------|------|
| 列出所有定时任务（名称、触发方式、配置、状态） | ✅ 已实现 |
| 展示上次/下次执行时间 | ✅ 已实现 |
| 展示最近错误信息 | ✅ 已实现 |
| 手动执行按钮（需确认） | ✅ 已实现 |
| 刷新按钮 | ✅ 已实现 |

### 1.2 缺失能力（待评估）

| 功能 | 优先级 | 说明 |
|------|--------|------|
| 执行历史记录 | 中 | 当前只有 last_run_at，无法查看历史执行记录和每次的统计结果 |
| 任务启用/禁用开关 | 中 | 当前只能查看状态，无法在界面上切换 |
| 执行结果统计 | 高 | task_generator 的 created/replaced/skipped/transferred 统计应可视化 |
| Cron 表达式可编辑 | 低 | 当前触发配置只读，修改需直接改数据库 |
| 执行耗时监控 | 低 | 无执行耗时记录 |

### 1.3 建议

trigger-jobs 页面的核心定位是"调度监控"，不宜过度膨胀。建议：
- 短期：在现有页面增加"最近执行结果"列（展示 task_generator 的 stats JSON）
- 中期：新增"执行历史"抽屉（点击任务名展开，展示最近 N 次执行记录）
- 长期：考虑是否需要独立的"调度中心"页面（类似 Airflow UI）

> **决策（O6）**：暂不新增 `biz.trigger_job_execution_log` 表。短期通过在 `trigger_jobs` 表新增 `last_stats JSONB` 字段记录最近一次执行统计即可满足需求。中期再评估是否需要完整执行历史表。

---

## 2. 新增页面设计

### 2.1 页面一：客户转移日志（Transfer Log）

**路由**：`/task-engine/transfer-log`

**目标用户**：运营管理员（超级管理员看全部，门店管理员看本店）

**数据源**：`biz.coach_task_transfer_log`

**展示内容**：

| 列 | 数据来源 | 说明 |
|----|---------|------|
| 转移时间 | `created_at` | 降序排列 |
| 门店 | `site_id` → `biz.sites.site_name` | JOIN 门店表 |
| 客户 | `member_id` → 会员昵称/手机号 | FDW 关联 ETL 库会员维度表 |
| 原助教 | `from_assistant_id` → 助教姓名 | FDW 关联 ETL 库助教维度表 |
| 新助教 | `to_assistant_id` → 助教姓名 | 同上 |
| 转移原因 | `transfer_reason` | 文本展示 |
| 转移得分 | `transfer_score` | 数值，保留 2 位小数 |
| 保护检查 | `guard_checks` | JSON → 三项检查结果（✅/❌） |

**筛选条件**：
- 门店（下拉选择，门店管理员自动锁定本店）
- 时间范围（日期选择器，默认最近 7 天）
- 助教（搜索框，支持原助教/新助教）

**操作**：
- 无写操作，纯查看
- 支持导出 CSV（后续迭代）

> **决策（O3）**：转移日志不关联 WBI/NCI/RS 快照。原因：(1) 转移时刻的指数值可通过 `transfer_reason` 文本和 `transfer_score` 间接推断；(2) 存储快照会增加 `coach_task_transfer_log` 表宽度，且历史指数可从 DWS 层按日期回溯。如后续运营反馈需要，可在 `guard_checks` JSONB 中追加指数快照字段，无需 DDL 变更。

---

### 2.2 页面二：待审核任务（Pending Review）

**路由**：`/task-engine/pending-review`

**目标用户**：运营管理员（超级管理员看全部，门店管理员看本店）

**数据源**：`biz.coach_tasks WHERE status = 'pending_review'`

**展示内容**：

| 列 | 数据来源 | 说明 |
|----|---------|------|
| 创建时间 | `created_at` | 降序排列 |
| 门店 | `site_id` → 门店名称 | |
| 客户 | `member_id` → 会员昵称 | FDW 关联 |
| 当前助教 | `assistant_id` → 助教姓名 | FDW 关联 |
| 任务类型 | `task_type` | 中文映射（见下方枚举表） |
| 累计转移次数 | `transfer_count` | |
| 优先级分 | `priority_score` | WBI/NCI 分值 |

**任务类型中文映射**：

| task_type | 中文 |
|-----------|------|
| `high_priority_recall` | 高优先召回 |
| `priority_recall` | 优先召回 |
| `follow_up_visit` | 客户回访 |
| `relationship_building` | 关系构建 |

**操作**：

| 操作 | 说明 |
|------|------|
| 重新分配 | 弹窗选择目标助教 → 调用 reassign API → 原任务标记 `transferred`，新任务 `active` |
| 关闭任务 | 弹窗填写关闭原因 → 调用 close API → 原任务标记 `inactive` |
| 查看转移历史 | 抽屉展示该客户的所有转移记录（复用转移日志 API） |

> **决策（O1）**：重新分配的候选助教列表获取方式 — 复用 P17 的转移候选逻辑。后端新增 `GET /api/admin/task-engine/pending-review/{task_id}/candidates` 端点，内部调用 `fdw_queries.get_pool_assistants(member_id)` 获取 POOL 助教列表，按转移得分排序返回。前端展示为下拉选择框，显示助教姓名 + 转移得分。若 POOL 为空，候选列表返回空数组，界面提示"该客户暂无符合转移条件的助教，请联系 ETL 团队确认数据覆盖情况"，不提供降级到全店助教的选项（P17 明确规定 UNASSIGNED 助教永远不分配）。若确实有紧急人工干预需求，可提供"强制指定"按钮，需额外二次确认弹窗，且操作记录中标注 `source: "manual_override"`，写入 `transfer_log.transfer_reason`。

---

### 2.3 页面三：任务引擎参数管理（Task Engine Config）

**路由**：`/task-engine/config`

**目标用户**：超级管理员（门店管理员只读）

**数据源**：`biz.cfg_task_generator_params`

**展示内容**：

表格形式，按参数分组，每行展示全局默认值 + 各门店覆盖值：

| 列 | 说明 |
|----|------|
| 参数名 | `param_key`，中文描述 |
| 全局默认值 | `site_id IS NULL` 的记录 |
| 门店覆盖值 | 特定 `site_id` 的记录（无覆盖时显示"使用默认"灰色标签） |
| 说明 | `description` 字段 |
| 操作 | 编辑 / 删除覆盖 |

**参数列表**（来自 P17 第 5 节）：

| 参数 | 默认值 | 中文说明 | 校验规则 |
|------|--------|---------|----------|
| `high_priority_recall_threshold` | 7.0 | 高优先召回阈值 | 0-10，numeric |
| `priority_recall_threshold` | 5.0 | 优先召回阈值 | 0-10，numeric |
| `rs_min_for_relationship` | 1.0 | 关系构建 RS 下限 | 0-10，numeric |
| `rs_max_for_relationship` | 6.0 | 关系构建 RS 上限 | 0-10，> rs_min |
| `consecutive_recall_fail_cycles` | 3 | 连续失败触发转移的轮数 | 1-10，integer |
| `min_wbi_for_transfer` | 5.0 | 触发转移的最低 WBI | 0-10，numeric |
| `guard_assistant_coverage_ratio` | 0.5 | 门店助教绑定率保护阈值 | 0-1，numeric |
| `guard_new_assistant_days` | 10 | 新助教入驻保护天数 | 1-90，integer |
| `transfer_score_w_rs` | 0.5 | 转移排序 RS 权重 | 0-1，三项之和 = 1 |
| `transfer_score_w_ms` | 0.3 | 转移排序 MS 权重 | 0-1，三项之和 = 1 |
| `transfer_score_w_ml` | 0.2 | 转移排序 ML 权重 | 0-1，三项之和 = 1 |
| `max_transfer_count` | 2 | 单客户最大转移次数 | 1-5，integer |
| `follow_up_visit_retention_hours` | 48 | 回访任务保留时长（小时） | 1-168，integer |

**操作**：

| 操作 | 说明 | 权限 |
|------|------|------|
| 编辑参数值 | 行内编辑，保存后立即生效 | 超级管理员 |
| 新增门店覆盖 | 选择门店 + 参数，设置覆盖值 | 超级管理员 |
| 删除门店覆盖 | 恢复使用全局默认值 | 超级管理员 |
| 重置为默认 | 将全局默认值恢复为 P17 定义的初始值 | 超级管理员 |

**前端校验规则**：
- 权重参数（`w_rs` + `w_ms` + `w_ml`）之和必须 = 1.0（容差 0.001）。前端将三个权重参数合并为一个"权重配置"卡片，同时展示三个输入框，整体保存。后端 PUT 接口对权重类参数做联合校验：收到任一权重参数修改时，自动读取当前另外两个权重的值做求和校验
- 阈值参数范围 0-10（与指数分值范围一致）
- `rs_max_for_relationship` 必须 > `rs_min_for_relationship`
- 修改后弹出确认对话框，展示变更前后对比

> **决策（O2）**：参数修改直接生效，不需要审批流程。原因：(1) 参数修改频率极低（预计每月 1-2 次）；(2) 修改记录通过 `updated_at` 字段和后端日志可追溯；(3) 引入审批流程会增加不必要的复杂度。后续如需审批，可在 `cfg_task_generator_params` 表新增 `updated_by` 字段记录操作人。

---

## 3. 后端 API 需求

### 3.1 转移日志 API

**路由文件**：`apps/backend/app/routers/admin_task_engine.py`（新建）

```
GET /api/admin/task-engine/transfer-log
  Query: site_id?, from_date?, to_date?, assistant_id?, page=1, page_size=20
  Response: { items: TransferLogItem[], total: int }
  权限: get_current_user（门店管理员自动按 site_id 过滤）

GET /api/admin/task-engine/transfer-log/{member_id}/history
  Response: { items: TransferLogItem[] }
  权限: get_current_user
```

**SQL 示例（转移日志列表）**：

```sql
SELECT
    tl.id, tl.site_id, tl.member_id,
    tl.from_assistant_id, tl.to_assistant_id,
    tl.transfer_reason, tl.transfer_score,
    tl.guard_checks, tl.created_at,
    s.site_name
FROM biz.coach_task_transfer_log tl
JOIN biz.sites s ON s.site_id = tl.site_id
WHERE ($1::bigint IS NULL OR tl.site_id = $1)
  AND ($2::date IS NULL OR tl.created_at >= $2)
  AND ($3::date IS NULL OR tl.created_at < $3 + INTERVAL '1 day')
  AND ($4::bigint IS NULL OR tl.from_assistant_id = $4 OR tl.to_assistant_id = $4)
ORDER BY tl.created_at DESC
LIMIT $5 OFFSET $6;
```

> **姓名关联实现方式**：由于 postgres_fdw 不传递 GUC 参数，不能在 admin-web 后端直接用 FDW JOIN。实现上采用 Python 层批量合并：先从 `biz.coach_task_transfer_log` 取分页数据，然后收集所有 `member_id` 和 `assistant_id`，批量调用 `fdw_queries.get_member_names(member_ids)` 和新增的 `fdw_queries.get_assistant_names(assistant_ids)` 工具函数（通过 `_fdw_context()` 创建独立 ETL 连接），最后在 Python 层做字典合并。待审核任务列表同理。避免在 SQL 里做跨库 JOIN。

### 3.2 待审核任务 API

```
GET /api/admin/task-engine/pending-review
  Query: site_id?, page=1, page_size=20
  Response: { items: PendingReviewItem[], total: int }
  权限: get_current_user

GET /api/admin/task-engine/pending-review/{task_id}/candidates
  Response: { candidates: CandidateAssistant[] }
  权限: get_current_user
  说明: 返回可接收转移的候选助教列表（POOL 助教 + 降级全店助教）

POST /api/admin/task-engine/pending-review/{task_id}/reassign
  Body: { to_assistant_id: int }
  Response: { success: bool, new_task_id: int }
  权限: get_current_user + roles 包含 'super_admin'
  副作用: 原任务 status → 'transferred'，新建 active 任务，写入 transfer_log

POST /api/admin/task-engine/pending-review/{task_id}/close
  Body: { reason: str }
  Response: { success: bool }
  权限: get_current_user + roles 包含 'super_admin'
  副作用: 任务 status → 'inactive'，abandon_reason = reason
```

### 3.3 参数管理 API

```
GET /api/admin/task-engine/config
  Query: site_id?（不传返回全局默认 + 所有门店覆盖）
  Response: { params: ConfigParam[] }
  权限: get_current_user

PUT /api/admin/task-engine/config/{param_id}
  Body: { param_value: float }
  Response: { success: bool }
  权限: get_current_user + roles 包含 'super_admin'

POST /api/admin/task-engine/config
  Body: { site_id: int, param_key: str, param_value: float }
  Response: { success: bool, id: int }
  权限: get_current_user + roles 包含 'super_admin'

DELETE /api/admin/task-engine/config/{param_id}
  Response: { success: bool }
  权限: get_current_user + roles 包含 'super_admin'
  约束: 不允许删除全局默认值（site_id IS NULL），仅允许删除门店覆盖
```

---

## 4. Pydantic Schema 定义

**文件位置**：`apps/backend/app/schemas/admin_task_engine.py`（新建）

```python
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field

# ── 转移日志 ──

class TransferLogItem(BaseModel):
    id: int
    site_id: int
    site_name: str = ""
    member_id: int
    member_name: str = ""          # FDW 关联
    from_assistant_id: int
    from_assistant_name: str = ""  # FDW 关联
    to_assistant_id: int
    to_assistant_name: str = ""    # FDW 关联
    transfer_reason: str | None = None
    transfer_score: float | None = None
    guard_checks: dict | None = None
    created_at: datetime

class TransferLogPage(BaseModel):
    items: list[TransferLogItem]
    total: int

# ── 待审核任务 ──

class PendingReviewItem(BaseModel):
    id: int
    site_id: int
    site_name: str = ""
    member_id: int
    member_name: str = ""
    assistant_id: int
    assistant_name: str = ""
    task_type: str
    task_type_label: str = ""      # 中文映射
    transfer_count: int = 0
    priority_score: float | None = None
    created_at: datetime

class PendingReviewPage(BaseModel):
    items: list[PendingReviewItem]
    total: int

class CandidateAssistant(BaseModel):
    assistant_id: int
    assistant_name: str = ""
    rs_display: float = 0
    ms_display: float = 0
    ml_display: float = 0
    transfer_score: float = 0      # w_rs*rs + w_ms*ms + w_ml*ml
    source: str = "pool"           # "pool" | "manual_override"（强制指定标记）

class CandidateListResponse(BaseModel):
    candidates: list[CandidateAssistant]

class ReassignRequest(BaseModel):
    to_assistant_id: int

class ReassignResponse(BaseModel):
    success: bool
    new_task_id: int | None = None

class CloseRequest(BaseModel):
    reason: str = Field(..., min_length=1, max_length=500)

class CloseResponse(BaseModel):
    success: bool

# ── 参数管理 ──

class ConfigParam(BaseModel):
    id: int
    site_id: int | None = None
    site_name: str | None = None   # site_id 非空时关联
    param_key: str
    param_value: float
    description: str | None = None
    updated_at: datetime

class ConfigParamList(BaseModel):
    params: list[ConfigParam]

class ConfigParamUpdate(BaseModel):
    param_value: float

class ConfigParamCreate(BaseModel):
    site_id: int
    param_key: str = Field(..., max_length=64)
    param_value: float

class ConfigParamResponse(BaseModel):
    success: bool
    id: int | None = None
```

---

## 5. 权限控制方案

> **决策（O5）**：采用双层权限模型 — 超级管理员（`super_admin`）全局访问 + 门店管理员（`site_admin`）本店只读。

### 5.1 权限矩阵

| 页面/操作 | super_admin | site_admin |
|-----------|-------------|------------|
| 转移日志 — 查看全部 | ✅ | ❌ |
| 转移日志 — 查看本店 | ✅ | ✅ |
| 待审核任务 — 查看全部 | ✅ | ❌ |
| 待审核任务 — 查看本店 | ✅ | ✅ |
| 待审核任务 — 重新分配 | ✅ | ❌ |
| 待审核任务 — 关闭任务 | ✅ | ❌ |
| 参数管理 — 查看 | ✅ | ✅（只读） |
| 参数管理 — 编辑/新增/删除 | ✅ | ❌ |

### 5.2 后端实现

> **与 P10 账号体系的关系**：P10 租户管理后台（`/tenant-admins`）的门店管理员账号与本页面的 `site_admin` 角色使用同一套 JWT 体系，通过 `roles` 字段区分权限范围。`site_admin` 在 P18 页面为只读访问，在 P10 页面可能有写操作权限。两者面向的用户群不同（P18 面向平台运营，P10 面向门店管理员），但共享认证基础设施。

```python
# apps/backend/app/auth/dependencies.py 中已有 CurrentUser(roles=[...])
# 新增权限检查辅助函数：

from fastapi import HTTPException, status

def require_super_admin(user: CurrentUser) -> None:
    """写操作权限检查：仅超级管理员可执行。"""
    if "super_admin" not in user.roles:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="仅超级管理员可执行此操作",
        )

def filter_by_site(user: CurrentUser, query_site_id: int | None) -> int | None:
    """读操作门店过滤：门店管理员强制锁定本店。"""
    if "super_admin" in user.roles:
        return query_site_id  # 超级管理员可查看任意门店
    return user.site_id       # 门店管理员强制本店
```

### 5.3 前端实现

```typescript
// 在页面组件中检查权限，控制操作按钮显示
const isSuperAdmin = user.roles.includes('super_admin');

// 转移日志：门店筛选器
// super_admin → 显示全部门店下拉
// site_admin → 隐藏门店筛选器，API 自动按 site_id 过滤

// 待审核任务：操作列
// super_admin → 显示"重新分配"和"关闭"按钮
// site_admin → 不显示操作列

// 参数管理：编辑按钮
// super_admin → 显示编辑/新增/删除按钮
// site_admin → 隐藏所有写操作按钮
```

---

## 6. 前端实现方案

### 6.1 导航结构

在 `apps/admin-web/src/App.tsx` 的 `NAV_ITEMS` 数组中新增"任务引擎"菜单组：

```typescript
// 新增 import
import { ApartmentOutlined } from "@ant-design/icons";
import TransferLog from "./pages/TransferLog";
import PendingReview from "./pages/PendingReview";
import TaskEngineConfig from "./pages/TaskEngineConfig";

// NAV_ITEMS 中新增（插入在"定时任务"之后）
{
  key: "task-engine-group",
  icon: <ApartmentOutlined />,
  label: "任务引擎",
  children: [
    { key: "/task-engine/transfer-log", label: "转移日志" },
    { key: "/task-engine/pending-review", label: "待审核任务" },
    { key: "/task-engine/config", label: "参数管理" },
  ],
},
```

**最终侧边栏结构**：

```
📋 任务配置          /
📋 任务管理          /task-manager
📊 ETL 状态          /etl-status
⏰ 定时任务          /trigger-jobs
🔀 任务引擎（新增）
  ├── 转移日志        /task-engine/transfer-log
  ├── 待审核任务      /task-engine/pending-review
  └── 参数管理        /task-engine/config
💾 数据库            /db-viewer
📄 日志              /log-viewer
⚙️ 环境配置          /env-config
🖥️ 运维面板          /ops-panel
👥 租户管理员        /tenant-admins
🤖 AI 监控
  ├── 运行总览        /ai/dashboard
  ├── 调度状态        /ai/trigger-jobs
  ├── 调用明细        /ai/run-logs
  └── 手动操作        /ai/operations
🐛 开发调试日志      /dev-trace
```

### 6.2 路由注册

在 `AppLayout` 的 `<Routes>` 中新增：

```tsx
<Route path="/task-engine/transfer-log" element={<TransferLog />} />
<Route path="/task-engine/pending-review" element={<PendingReview />} />
<Route path="/task-engine/config" element={<TaskEngineConfig />} />
```

同时修改 `<Menu>` 的 `defaultOpenKeys`，补充 `task-engine-group` 前缀：

```tsx
defaultOpenKeys={[
  ...(location.pathname.startsWith("/ai/") ? ["ai-group"] : []),
  ...(location.pathname.startsWith("/task-engine/") ? ["task-engine-group"] : []),
]}
```

### 6.3 API 模块

新建 `apps/admin-web/src/api/taskEngine.ts`：

```typescript
import { apiClient } from "./client";

// ── 转移日志 ──
export interface TransferLogItem { /* 同 Pydantic schema */ }
export interface TransferLogPage { items: TransferLogItem[]; total: number; }

export async function fetchTransferLog(params: {
  site_id?: number; from_date?: string; to_date?: string;
  assistant_id?: number; page?: number; page_size?: number;
}): Promise<TransferLogPage> {
  const { data } = await apiClient.get("/admin/task-engine/transfer-log", { params });
  return data;
}

export async function fetchMemberTransferHistory(memberId: number): Promise<TransferLogItem[]> {
  const { data } = await apiClient.get(`/admin/task-engine/transfer-log/${memberId}/history`);
  return data.items;
}

// ── 待审核任务 ──
export interface PendingReviewItem { /* 同 Pydantic schema */ }

export async function fetchPendingReview(params: {
  site_id?: number; page?: number; page_size?: number;
}): Promise<{ items: PendingReviewItem[]; total: number }> {
  const { data } = await apiClient.get("/admin/task-engine/pending-review", { params });
  return data;
}

export async function fetchCandidates(taskId: number) {
  const { data } = await apiClient.get(`/admin/task-engine/pending-review/${taskId}/candidates`);
  return data.candidates;
}

export async function reassignTask(taskId: number, toAssistantId: number) {
  const { data } = await apiClient.post(`/admin/task-engine/pending-review/${taskId}/reassign`, {
    to_assistant_id: toAssistantId,
  });
  return data;
}

export async function closeTask(taskId: number, reason: string) {
  const { data } = await apiClient.post(`/admin/task-engine/pending-review/${taskId}/close`, {
    reason,
  });
  return data;
}

// ── 参数管理 ──
export interface ConfigParam { /* 同 Pydantic schema */ }

export async function fetchConfig(siteId?: number): Promise<ConfigParam[]> {
  const { data } = await apiClient.get("/admin/task-engine/config", {
    params: siteId ? { site_id: siteId } : {},
  });
  return data.params;
}

export async function updateConfig(paramId: number, value: number) {
  const { data } = await apiClient.put(`/admin/task-engine/config/${paramId}`, {
    param_value: value,
  });
  return data;
}

export async function createConfig(siteId: number, paramKey: string, value: number) {
  const { data } = await apiClient.post("/admin/task-engine/config", {
    site_id: siteId, param_key: paramKey, param_value: value,
  });
  return data;
}

export async function deleteConfig(paramId: number) {
  const { data } = await apiClient.delete(`/admin/task-engine/config/${paramId}`);
  return data;
}
```

### 6.4 页面组件结构

每个页面遵循现有 `TriggerJobs.tsx` 的模式：`useState` + `useCallback` + `useEffect` + Ant Design Table。

| 文件 | 说明 |
|------|------|
| `apps/admin-web/src/pages/TransferLog.tsx` | 转移日志页面 |
| `apps/admin-web/src/pages/PendingReview.tsx` | 待审核任务页面 |
| `apps/admin-web/src/pages/TaskEngineConfig.tsx` | 参数管理页面 |
| `apps/admin-web/src/api/taskEngine.ts` | API 调用模块 |
| `apps/backend/app/routers/admin_task_engine.py` | 后端路由 |
| `apps/backend/app/schemas/admin_task_engine.py` | Pydantic schema |

---

## 7. 数据库变更需求

### 7.1 trigger_jobs 表扩展（P1 优先级）

> `last_error` 和 `description` 字段已存在于数据库中（已通过 `\d biz.trigger_jobs` 确认），无需重复添加。

```sql
-- 仅 last_stats 是真正新增的
ALTER TABLE biz.trigger_jobs
  ADD COLUMN IF NOT EXISTS last_stats JSONB;

COMMENT ON COLUMN biz.trigger_jobs.last_stats
  IS '最近一次执行的统计结果 JSON，如 {"created":5,"replaced":2,"skipped":10,"transferred":1}';
```

`last_stats` 由 `task_generator.run()` 在执行完成后写入，是运营监控任务引擎健康度的最直接手段。

### 7.2 cfg_task_generator_params 表扩展

```sql
-- 记录修改人（审计追溯）
ALTER TABLE biz.cfg_task_generator_params
  ADD COLUMN IF NOT EXISTS updated_by BIGINT;

COMMENT ON COLUMN biz.cfg_task_generator_params.updated_by IS '最近修改人 user_id，用于审计追溯';
```

### 7.3 无需新建表

P18 不新增业务表。所有数据来源于 P17 已创建的：
- `biz.coach_task_transfer_log`（转移日志）
- `biz.cfg_task_generator_params`（参数配置）
- `biz.coach_tasks`（任务表，含 `pending_review` 状态）

---

## 8. 优先级排序

| 优先级 | 功能 | 理由 | 预估工时 |
|--------|------|------|----------|
| P0 | 待审核任务页面 | `pending_review` 任务需要人工介入，无此页面则转移超限的任务无法处理 | 后端 4h + 前端 4h |
| P1 | 参数管理页面 | 门店级参数调整是日常运营需求，否则每次改参数都要直接改数据库 | 后端 3h + 前端 4h |
| P1 | 转移日志页面 | 运营需要追踪转移效果，但短期可通过数据库查询替代 | 后端 3h + 前端 3h |
| P1 | trigger-jobs last_stats 展示 | 任务引擎上线后运营最直接的监控手段，能看到 created/replaced/skipped/transferred 数字 | 后端 1h + 前端 1h |
| P2 | trigger-jobs 启用/禁用开关 | 低频操作，可通过数据库修改 | 后端 0.5h + 前端 0.5h |

**建议实施顺序**：
1. 后端：先建 router + schema 骨架（`admin_task_engine.py`），再逐个实现端点
2. 前端：先注册路由和导航，再逐个实现页面组件
3. P0 → P1（参数管理）→ P1（转移日志）→ P2 → P3

---

## 9. 开放问题决策汇总

| # | 问题 | 决策 | 理由 |
|---|------|------|------|
| O1 | 重新分配候选助教列表获取方式 | 复用 P17 转移候选逻辑（仅 POOL 助教），POOL 为空时返回空列表 + 提示，紧急情况提供"强制指定"（需二次确认 + manual_override 标记） | 严格遵守 P17 的 UNASSIGNED 永不分配约束 |
| O2 | 参数修改是否需要审批流程 | 直接生效，不需要审批 | 修改频率极低，`updated_at` + `updated_by` 可追溯 |
| O3 | 转移日志是否关联 WBI/NCI/RS 快照 | 不关联，通过 `guard_checks` JSONB 间接推断 | 避免表膨胀，历史指数可从 DWS 层回溯 |
| O4 | 是否需要运行总览 Dashboard | 暂不需要，后续根据运营反馈评估 | 当前数据量不足以支撑有意义的趋势图 |
| O5 | 权限模型 | 双层：super_admin 全局 + site_admin 本店只读 | 与现有 JWT roles 体系一致，实现成本最低 |
| O6 | trigger-jobs 执行历史是否需要新表 | 暂不新增，用 `last_stats` JSONB 字段过渡 | 短期够用，中期再评估完整历史表 |

> **决策（O4）**：暂不建设运行总览 Dashboard。原因：(1) P17 刚上线，历史数据不足以生成有意义的趋势图；(2) 三个功能页面已覆盖核心运营需求；(3) 后续积累 1-2 个月数据后，可作为 P19 或 P20 独立评估。

---

## 10. 验收标准

| # | 验收项 | 判定方式 |
|---|--------|----------|
| AC1 | 转移日志页面可按门店/时间/助教筛选 | 手动验证筛选条件组合 |
| AC2 | 门店管理员只能看到本店转移日志 | 用 site_admin 角色登录验证 |
| AC3 | 待审核任务页面展示所有 pending_review 任务 | 数据库插入测试数据后验证 |
| AC4 | 重新分配操作正确创建新任务并标记原任务 | 执行后检查 coach_tasks + transfer_log |
| AC5 | 关闭任务操作正确更新状态和原因 | 执行后检查 coach_tasks.status + abandon_reason |
| AC6 | 参数管理页面展示全局默认 + 门店覆盖 | 插入门店覆盖数据后验证 |
| AC7 | 权重参数之和校验生效 | 尝试设置不等于 1.0 的权重组合 |
| AC8 | 超级管理员可执行所有写操作 | 用 super_admin 角色验证 |
| AC9 | 门店管理员无法执行写操作 | 用 site_admin 角色验证 403 |
| AC10 | 不允许删除全局默认参数 | 尝试删除 site_id IS NULL 的记录 |

---

## 11. 与现有 PRD/模块的关系

| 文档/模块 | 关系说明 |
|-----------|----------|
| P17 | 本 PRD 是 P17 的管理后台配套，P17 提供数据，P18 提供可视化和操作入口 |
| P10（租户管理后台） | P10 是面向门店管理员的独立应用，P18 是面向平台运营的 admin-web 扩展。两者共享 JWT 认证体系（同一套 `roles` 字段），`site_admin` 在 P18 为只读、在 P10 可能有写权限。详见第 5.2 节说明 |
| trigger-jobs 现有页面 | 本 PRD 评估其扩展需求，但不强制改动，保持现有功能稳定 |
| AI 监控模块 | 参考其 API 设计模式（JWT + admin 角色、分页列表 + 详情）|

---

## 附录 A：后端路由注册

在 `apps/backend/app/main.py` 中注册新路由：

```python
from app.routers import admin_task_engine
app.include_router(admin_task_engine.router)
```

## 附录 B：变更历史

| 版本 | 日期 | 变更内容 |
|------|------|----------|
| v1.0-draft | 2026-03-24 | 初始草稿，定义 3 个页面 + 9 个 API + 6 个开放问题 |
| v2.0-ready-for-review | 2026-03-24 | 关闭全部 6 个开放问题；补充 Pydantic schema、权限矩阵、前端实现方案、SQL 示例、校验规则、验收标准；升级为待评审 |
| v2.1-reviewed | 2026-03-24 | 评审修正：(1) DDL 移除已存在的 last_error/description，仅新增 last_stats；(2) defaultOpenKeys 补充 task-engine-group；(3) last_stats 优先级 P2→P1；(4) 候选助教降级方案改为空结果+提示+强制指定，不放开到全店助教；(5) 权重参数改为卡片整体编辑+联合校验；(6) 明确姓名关联走 Python 层批量合并；(7) 补充 site_admin 与 P10 账号体系关系说明 |
