# BD_Manual：biz Schema 业务表（助教任务系统 + 备注系统 + 触发器调度）

> 目标库：`test_zqyy_app`（通过 `APP_DB_DSN` 连接）
> 迁移脚本：
>   - `db/zqyy_app/migrations/2026-02-27__p4_create_biz_tables.sql`（建表）
>   - `db/zqyy_app/migrations/2026-02-27__p4_seed_trigger_jobs.sql`（种子数据）
> 关联 SPEC：`04-miniapp-core-business`（P4 小程序核心业务模块）

---

## 1. 变更说明

### 新增表（4 张）

| # | 表名 | 用途 | 字段数 |
|---|------|------|--------|
| 1 | `biz.coach_tasks` | 助教任务表：存储任务分配、状态、有效期、置顶、放弃原因等 | 15 |
| 2 | `biz.coach_task_history` | 任务变更历史表：记录任务关闭/新建/置顶/放弃的追溯链 | 9 |
| 3 | `biz.notes` | 统一备注表：通过 `type` 字段区分普通备注/回访备注/放弃原因，含星星评分 | 15 |
| 4 | `biz.trigger_jobs` | 触发器配置表：存储 cron/interval/event 三种触发方式的配置与执行状态 | 9 |

### 表字段明细

#### biz.coach_tasks（15 字段）

| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | BIGSERIAL | PK | 自增主键 |
| `site_id` | BIGINT | NOT NULL | 门店 ID（多门店隔离） |
| `assistant_id` | BIGINT | NOT NULL | 助教 ID |
| `member_id` | BIGINT | NOT NULL | 客户 ID |
| `task_type` | VARCHAR(50) | NOT NULL | 任务类型：`high_priority_recall` / `priority_recall` / `follow_up_visit` / `relationship_building` |
| `status` | VARCHAR(20) | NOT NULL DEFAULT 'active' | 状态：`active` / `inactive` / `completed` / `abandoned` |
| `priority_score` | NUMERIC(5,2) | 可空 | 优先级分数，取 `max(WBI, NCI)` 快照 |
| `expires_at` | TIMESTAMPTZ | 可空 | 有效期时间戳，NULL 表示无限期 |
| `is_pinned` | BOOLEAN | DEFAULT FALSE | 是否置顶 |
| `abandon_reason` | TEXT | 可空 | 放弃原因（放弃时必填） |
| `completed_at` | TIMESTAMPTZ | 可空 | 完成时间 |
| `completed_task_type` | VARCHAR(50) | 可空 | 完成时的任务类型快照 |
| `parent_task_id` | BIGINT | FK → `biz.coach_tasks(id)`，可空 | 父任务 ID（自引用） |
| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| `updated_at` | TIMESTAMPTZ | DEFAULT NOW() | 更新时间 |

#### biz.coach_task_history（9 字段）

| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | BIGSERIAL | PK | 自增主键 |
| `task_id` | BIGINT | NOT NULL, FK → `biz.coach_tasks(id)` | 关联任务 |
| `action` | VARCHAR(50) | NOT NULL | 操作类型：`created` / `type_changed` / `pinned` / `abandoned` / `cancel_abandon` / `expired` / `completed` |
| `old_status` | VARCHAR(20) | 可空 | 变更前状态 |
| `new_status` | VARCHAR(20) | 可空 | 变更后状态 |
| `old_task_type` | VARCHAR(50) | 可空 | 变更前任务类型 |
| `new_task_type` | VARCHAR(50) | 可空 | 变更后任务类型 |
| `detail` | JSONB | 可空 | 附加详情（如放弃原因等） |
| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 记录时间 |

#### biz.notes（15 字段）

| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | BIGSERIAL | PK | 自增主键 |
| `site_id` | BIGINT | NOT NULL | 门店 ID |
| `user_id` | INTEGER | NOT NULL | 小程序用户 ID |
| `target_type` | VARCHAR(50) | NOT NULL | 目标类型（如 `member`） |
| `target_id` | BIGINT | NOT NULL | 目标 ID（如 member_id） |
| `type` | VARCHAR(20) | NOT NULL DEFAULT 'normal' | 备注类型：`normal` / `follow_up` / `abandon_reason` |
| `content` | TEXT | NOT NULL | 备注内容 |
| `rating_service_willingness` | SMALLINT | CHECK (1-5)，可空 | 再次服务意愿评分 |
| `rating_revisit_likelihood` | SMALLINT | CHECK (1-5)，可空 | 再来店可能性评分 |
| `task_id` | BIGINT | FK → `biz.coach_tasks(id)`，可空 | 关联任务 |
| `ai_score` | SMALLINT | 可空 | AI 应用 6 评分（P5 实现） |
| `ai_analysis` | TEXT | 可空 | AI 分析结果（P5 实现） |
| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| `updated_at` | TIMESTAMPTZ | DEFAULT NOW() | 更新时间 |
| `score` | SMALLINT | CHECK (1-5)，可空 | 备注星星评分，助教创建备注时可选填写，不参与 AI 分析（RNS1.1 新增） |

#### biz.trigger_jobs（9 字段）

| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | SERIAL | PK | 自增主键 |
| `job_type` | VARCHAR(100) | NOT NULL | 任务类型标识，映射到 Python handler |
| `job_name` | VARCHAR(100) | NOT NULL, UNIQUE | 任务名称（唯一） |
| `trigger_condition` | VARCHAR(20) | NOT NULL | 触发方式：`cron` / `interval` / `event` |
| `trigger_config` | JSONB | NOT NULL | 触发配置（cron 表达式 / 间隔秒数 / 事件名） |
| `last_run_at` | TIMESTAMPTZ | 可空 | 上次运行时间 |
| `next_run_at` | TIMESTAMPTZ | 可空 | 下次运行时间（event 类型为 NULL） |
| `status` | VARCHAR(20) | NOT NULL DEFAULT 'enabled' | 状态：`enabled` / `disabled` |
| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |

### 约束与索引

| 表 | 约束/索引名 | 类型 | 说明 |
|----|-----------|------|------|
| `coach_tasks` | `idx_coach_tasks_site_assistant_member_type` | UNIQUE INDEX (partial) | `(site_id, assistant_id, member_id, task_type) WHERE status = 'active'`，保证同一组合下活跃任务最多一条 |
| `coach_tasks` | `idx_coach_tasks_assistant_status` | INDEX | `(site_id, assistant_id, status)`，助教任务列表查询加速 |
| `coach_tasks` | FK `parent_task_id` | FK | → `biz.coach_tasks(id)`，自引用 |
| `coach_task_history` | FK `task_id` | FK | → `biz.coach_tasks(id)` |
| `notes` | `idx_notes_target` | INDEX | `(site_id, target_type, target_id)`，按目标查询备注加速 |
| `notes` | CHECK `rating_service_willingness` | CHECK | `BETWEEN 1 AND 5` |
| `notes` | CHECK `rating_revisit_likelihood` | CHECK | `BETWEEN 1 AND 5` |
| `notes` | CHECK `score` | CHECK | `score IS NULL OR (score >= 1 AND score <= 5)`（RNS1.1 新增） |
| `notes` | FK `task_id` | FK | → `biz.coach_tasks(id)` |
| `trigger_jobs` | UNIQUE `job_name` | UNIQUE | 触发器名称唯一 |

### 种子数据（4 条触发器配置）

| job_name | job_type | trigger_condition | trigger_config | next_run_at | 说明 |
|----------|----------|-------------------|----------------|-------------|------|
| `task_generator` | `task_generator` | `cron` | `{"cron_expression": "0 4 * * *"}` | 次日 04:00 | 每日凌晨 4:00 运行任务生成器 |
| `task_expiry_check` | `task_expiry_check` | `interval` | `{"interval_seconds": 3600}` | NOW() + 1h | 每小时检查过期任务 |
| `recall_completion_check` | `recall_completion_check` | `event` | `{"event_name": "etl_data_updated"}` | NULL | ETL 数据更新后触发召回完成检测 |
| `note_reclassify_backfill` | `note_reclassify_backfill` | `event` | `{"event_name": "recall_completed"}` | NULL | 召回完成后触发备注回溯重分类 |

---

## 2. 兼容性影响

| 组件 | 影响 |
|------|------|
| ETL 任务 | 无直接影响。`biz` Schema 表不参与 ETL 流程，但任务生成器通过 FDW 只读访问 ETL 库的 WBI/NCI/RS 指数数据 |
| 后端 API | 直接依赖。FastAPI 后端将基于这些表实现任务 CRUD（`/api/xcx/tasks`）、备注 CRUD（`/api/xcx/notes`）、触发器调度等功能 |
| 小程序 | 间接依赖。小程序通过后端 API 间接使用任务列表、备注功能 |
| 管理后台 | 暂无影响。后续可能增加任务监控和触发器管理界面 |
| FDW 配置 | 无影响。`fdw_etl` Schema 独立于 `biz`，任务生成器和召回检测器通过 FDW 只读查询 ETL 库 |
| `auth` Schema | 间接依赖。任务生成器通过 `auth.user_assistant_binding` 确定助教与小程序用户的映射关系 |
| `public` Schema | 无影响。`member_retention_clue` 表独立于本次变更 |
| 现有 `biz` Schema | 兼容。`biz` Schema 已由 P1 迁移脚本创建，本次仅在其中新增 4 张表，不修改已有对象 |

---

## 2.1 RNS1.3 看板接口引用说明（2026-03-20 补充）

RNS1.3（三看板接口）BOARD-1 助教看板的任务维度查询引用了 `biz.coach_tasks`（无 schema 变更，仅新增读取路径）：

| 表 | 引用接口 | 用途 |
|----|---------|------|
| `biz.coach_tasks` | BOARD-1 `_query_coach_tasks()` | 按 `site_id` + `assistant_id` + 日期范围查询任务完成数，按 `task_type` 分类统计 `recall`（召回类：`high_priority_recall` / `priority_recall`）和 `callback`（回访类：`follow_up_visit` / `relationship_building`），筛选条件 `status = 'completed'` + `completed_at BETWEEN start_date AND end_date` |

查询模式：
```sql
SELECT assistant_id, task_type, COUNT(*) AS cnt
FROM biz.coach_tasks
WHERE site_id = :site_id
  AND assistant_id = ANY(:assistant_ids)
  AND status = 'completed'
  AND completed_at BETWEEN :start_date AND :end_date
GROUP BY assistant_id, task_type
```

> 该查询走 `idx_coach_tasks_assistant_status` 索引（`site_id, assistant_id, status`），无需新增索引。

---

## 2.2 RNS1.2 接口引用说明（2026-03-18 补充）

RNS1.2（客户与助教接口）新增 3 个端点，引用了以下 biz/public 表（无 schema 变更，仅新增读取路径）：

| 表 | 引用接口 | 用途 |
|----|---------|------|
| `biz.coach_tasks` | CUST-1 `coachTasks` 模块、COACH-1 `visibleTasks`/`hiddenTasks`/`abandonedTasks`/`tasksCompleted` | 查询客户关联的助教任务（按 `member_id`）；查询助教任务分组（按 `assistant_id`、`status` 分组）；统计当月完成任务数 |
| `biz.notes` | CUST-1 `notes` 模块、COACH-1 `notes`/任务备注 | 查询客户备注（`target_type='member'`，最多 20 条）；查询助教相关备注（最多 20 条）；查询任务关联备注（`task_id` 关联） |
| `biz.ai_cache` | CUST-1 `aiInsight` 模块 | 查询 AI 分析缓存（`cache_type='app4_analysis'`，`target_id=customerId`），解析 `cache_value` JSON 生成洞察摘要和策略建议 |
| `public.member_retention_clue` | CUST-1 `retentionClues` 模块 | 查询维客线索（按 `created_at` 倒序），格式与 TASK-2 一致 |

> 以上均为只读查询，不涉及表结构变更。`biz.ai_cache` 表由 P5（AI 集成）创建，`public.member_retention_clue` 表由独立迁移创建。

---

## 3. 回滚策略

按逆序 `DROP TABLE IF EXISTS CASCADE`（迁移脚本末尾已包含注释形式的回滚语句）：

```sql
-- 先删除种子数据（如需保留表结构）
DELETE FROM biz.trigger_jobs
WHERE job_name IN (
    'task_generator',
    'task_expiry_check',
    'recall_completion_check',
    'note_reclassify_backfill'
);

-- 删除索引
DROP INDEX IF EXISTS biz.idx_notes_target;
DROP INDEX IF EXISTS biz.idx_coach_tasks_assistant_status;
DROP INDEX IF EXISTS biz.idx_coach_tasks_site_assistant_member_type;

-- 删除表（按逆序，CASCADE 处理外键依赖）
DROP TABLE IF EXISTS biz.trigger_jobs CASCADE;
DROP TABLE IF EXISTS biz.notes CASCADE;
DROP TABLE IF EXISTS biz.coach_task_history CASCADE;
DROP TABLE IF EXISTS biz.coach_tasks CASCADE;
```

注意：
- `CASCADE` 会级联删除依赖对象（外键引用的子表数据）
- 如果表中已有业务数据，需先备份再执行回滚
- 回滚不会删除 `biz` Schema 本身（由 P1 创建，其他表可能依赖）

---

## 4. 验证 SQL

```sql
-- 1. 验证 biz Schema 下 4 张业务表全部存在
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'biz'
  AND table_name IN ('coach_tasks', 'coach_task_history', 'notes', 'trigger_jobs')
ORDER BY table_name;
-- 预期：返回 4 行（coach_task_history, coach_tasks, notes, trigger_jobs）

-- 2. 验证 coach_tasks 表字段数量和关键字段
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'coach_tasks'
ORDER BY ordinal_position;
-- 预期：返回 15 行，包含 id/site_id/assistant_id/member_id/task_type/status 等

-- 3. 验证部分唯一索引存在
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'biz' AND indexname = 'idx_coach_tasks_site_assistant_member_type';
-- 预期：返回 1 行，indexdef 包含 "WHERE ((status)::text = 'active'::text)"

-- 4. 验证 notes 表的 CHECK 约束（评分 1-5）
SELECT conname, pg_get_constraintdef(oid) AS constraint_def
FROM pg_constraint
WHERE conrelid = 'biz.notes'::regclass AND contype = 'c';
-- 预期：返回 2 行，分别约束 rating_service_willingness 和 rating_revisit_likelihood 在 1-5 范围

-- 5. 验证种子数据：4 条触发器配置
SELECT job_name, job_type, trigger_condition,
       trigger_config->>'cron_expression' AS cron,
       trigger_config->>'interval_seconds' AS interval_sec,
       trigger_config->>'event_name' AS event
FROM biz.trigger_jobs
WHERE job_name IN ('task_generator', 'task_expiry_check', 'recall_completion_check', 'note_reclassify_backfill')
ORDER BY job_name;
-- 预期：返回 4 行
--   note_reclassify_backfill | event   | recall_completed
--   recall_completion_check  | event   | etl_data_updated
--   task_expiry_check        | interval| interval_seconds=3600
--   task_generator           | cron    | 0 4 * * *

-- 6. 验证查询索引存在
SELECT indexname
FROM pg_indexes
WHERE schemaname = 'biz'
  AND indexname IN ('idx_coach_tasks_assistant_status', 'idx_notes_target')
ORDER BY indexname;
-- 预期：返回 2 行
```
