# 技术设计文档 — RNS1.1：任务与绩效接口

## 概述

RNS1.1 实现助教日常使用频率最高的 4 个核心接口及配套前端适配，覆盖 6 个任务：

1. **T1-1 扩展 TASK-1**：任务列表 `performance` 从 4 字段扩展到 15+ 字段；`enrichTask` 补充 `lastVisitDays`/`balance`/`aiSuggestion`
2. **T1-2 实现 TASK-2**：任务详情完整版（维客线索、话术、服务记录、AI 分析、备注）
3. **T1-3 实现 PERF-1**：绩效概览（DateGroup 分组、收入档位、新客/常客列表）
4. **T1-4 实现 PERF-2**：绩效明细（按月分页、DateGroup 分组）
5. **T1-5 pin/unpin**：已有端点的响应格式对齐契约
6. **T1-6 前端适配**：createNote 补 score、月份切换、avatarChar/avatarColor 前端计算

### 设计原则

- **增量扩展**：在现有 `xcx_tasks.py` 路由和 `task_manager.py` 服务基础上扩展，不重写
- **FDW 查询集中化**：所有 FDW 查询封装在 service 层，路由层不直接操作数据库
- **DWD-DOC 强制规则**：金额 `items_sum` 口径、助教费用 `assistant_pd_money` + `assistant_cx_money` 拆分、会员信息通过 `member_id` JOIN `dim_member`
- **契约驱动**：响应结构严格遵循 `API-contract.md` 定义

### 依赖

- RNS1.0 已完成：`ResponseWrapperMiddleware`、`CamelModel`、前端 `request()` 解包
- 现有代码：`xcx_tasks.py`（路由）、`task_manager.py`（服务）、`xcx_notes.py`（备注路由）

## 架构

### 模块交互

```mermaid
graph TB
    subgraph "微信小程序"
        A[task-list 页面] --> B[services/api.ts]
        C[task-detail 页面] --> B
        D[performance 页面] --> B
        E[performance-records 页面] --> B
    end

    subgraph "FastAPI 后端"
        F[routers/xcx_tasks.py<br/>TASK-1/2, PIN/UNPIN]
        G[routers/xcx_performance.py<br/>PERF-1/2 — 新增]
        H[services/task_manager.py<br/>任务 CRUD + 扩展]
        I[services/performance_service.py<br/>绩效查询 — 新增]
        J[services/fdw_queries.py<br/>FDW 查询封装 — 新增]
    end

    subgraph "数据库"
        K[(zqyy_app<br/>biz.coach_tasks<br/>biz.ai_cache<br/>biz.notes<br/>auth.user_assistant_binding<br/>public.member_retention_clue)]
        L[(etl_feiqiu via FDW<br/>fdw_etl.v_dws_assistant_salary_calc<br/>fdw_etl.v_dwd_assistant_service_log<br/>fdw_etl.v_dim_member<br/>fdw_etl.v_dim_member_card_account)]
    end

    B -->|HTTP JSON| F
    B -->|HTTP JSON| G
    F --> H
    G --> I
    H --> J
    I --> J
    H --> K
    I --> K
    J -->|FDW + SET LOCAL| L

    style G fill:#9f9,stroke:#333
    style I fill:#9f9,stroke:#333
    style J fill:#9f9,stroke:#333
```

### 请求流程（以 TASK-1 扩展为例）

```mermaid
sequenceDiagram
    participant MP as 小程序
    participant R as xcx_tasks Router
    participant TM as task_manager
    participant FDW as fdw_queries
    participant BIZ as zqyy_app
    participant ETL as etl via FDW

    MP->>R: GET /api/xcx/tasks?status=pending&page=1
    R->>TM: get_task_list(user_id, site_id, status, page, pageSize)
    TM->>BIZ: SELECT assistant_id FROM user_assistant_binding
    TM->>BIZ: SELECT tasks FROM coach_tasks
    TM->>FDW: enrich_tasks(member_ids, assistant_id, site_id)
    FDW->>ETL: SET LOCAL app.current_site_id
    FDW->>ETL: SELECT dim_member (姓名)
    FDW->>ETL: SELECT dim_member_card_account (余额)
    FDW->>ETL: SELECT dwd_settlement_head (lastVisitDays)
    FDW-->>TM: enriched data
    TM->>FDW: get_performance_summary(assistant_id, site_id)
    FDW->>ETL: SELECT v_dws_assistant_salary_calc (档位/收入)
    FDW-->>TM: PerformanceSummary
    TM->>BIZ: SELECT ai_cache (aiSuggestion)
    TM-->>R: TaskListResponse
    R-->>MP: { code: 0, data: TaskListResponse }
```

## 组件与接口

### 组件 1：xcx_tasks Router 扩展

**位置**：`apps/backend/app/routers/xcx_tasks.py`

**改动**：
- `GET /api/xcx/tasks` — 响应从 `list[TaskListItem]` 改为 `TaskListResponse`（含 `items` + `performance` + 分页）
- 新增 `GET /api/xcx/tasks/{taskId}` — 任务详情端点
- `POST .../pin` 和 `POST .../unpin` — 响应对齐契约格式 `{ isPinned: bool }`
- 新增查询参数：`status`（筛选）、`page`、`pageSize`

```python
@router.get("", response_model=TaskListResponse)
async def get_tasks(
    status: str = Query("pending", regex="^(pending|completed|abandoned)$"),
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=100),
    user: CurrentUser = Depends(require_approved()),
):
    """获取任务列表 + 绩效概览。"""
    return await task_manager.get_task_list_v2(
        user.user_id, user.site_id, status, page, page_size
    )

@router.get("/{task_id}", response_model=TaskDetailResponse)
async def get_task_detail(
    task_id: int,
    user: CurrentUser = Depends(require_approved()),
):
    """获取任务详情完整版。"""
    return await task_manager.get_task_detail(
        task_id, user.user_id, user.site_id
    )
```

### 组件 2：xcx_performance Router（新增）

**位置**：`apps/backend/app/routers/xcx_performance.py`

**职责**：绩效概览和绩效明细两个端点。

```python
router = APIRouter(prefix="/api/xcx/performance", tags=["小程序绩效"])

@router.get("", response_model=PerformanceOverviewResponse)
async def get_performance_overview(
    year: int = Query(...),
    month: int = Query(..., ge=1, le=12),
    user: CurrentUser = Depends(require_approved()),
):
    """绩效概览（PERF-1）。"""
    return await performance_service.get_overview(
        user.user_id, user.site_id, year, month
    )

@router.get("/records", response_model=PerformanceRecordsResponse)
async def get_performance_records(
    year: int = Query(...),
    month: int = Query(..., ge=1, le=12),
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=100),
    user: CurrentUser = Depends(require_approved()),
):
    """绩效明细（PERF-2）。"""
    return await performance_service.get_records(
        user.user_id, user.site_id, year, month, page, page_size
    )
```

**注册**（`main.py`）：
```python
from app.routers import xcx_performance
app.include_router(xcx_performance.router)
```

### 组件 3：fdw_queries 服务（新增）

**位置**：`apps/backend/app/services/fdw_queries.py`

**职责**：封装所有 FDW 查询，统一 `SET LOCAL app.current_site_id` 隔离。所有 FDW 查询集中在此模块，避免 service 层散落 SQL。

**核心函数**：

```python
def _fdw_context(conn, site_id: int):
    """上下文管理器：BEGIN + SET LOCAL app.current_site_id。"""
    ...

def get_member_info(conn, site_id: int, member_ids: list[int]) -> dict[int, MemberInfo]:
    """
    批量查询会员信息。
    ⚠️ DQ-6：通过 member_id JOIN fdw_etl.v_dim_member 获取 nickname/mobile，
    禁止使用 settlement_head.member_phone/member_name。
    """
    # SELECT member_id, nickname, mobile
    # FROM fdw_etl.v_dim_member
    # WHERE member_id = ANY(%s) AND scd2_is_current = 1

def get_member_balance(conn, site_id: int, member_ids: list[int]) -> dict[int, Decimal]:
    """
    批量查询会员储值卡余额。
    ⚠️ DQ-7：通过 member_id JOIN fdw_etl.v_dim_member_card_account。
    """

def get_last_visit_days(conn, site_id: int, member_ids: list[int]) -> dict[int, int | None]:
    """
    批量查询客户距上次到店天数。
    来源：fdw_etl.v_dwd_assistant_service_log 或 dwd_settlement_head。
    """

def get_salary_calc(conn, site_id: int, assistant_id: int, year: int, month: int) -> dict | None:
    """
    查询助教绩效/档位/收入数据。
    来源：fdw_etl.v_dws_assistant_salary_calc。
    ⚠️ DWD-DOC 规则 1：收入使用 items_sum 口径。
    ⚠️ DWD-DOC 规则 2：费用使用 assistant_pd_money + assistant_cx_money。
    """

def get_service_records(
    conn, site_id: int, assistant_id: int,
    year: int, month: int, limit: int, offset: int
) -> list[dict]:
    """
    查询助教服务记录明细。
    来源：fdw_etl.v_dwd_assistant_service_log。
    ⚠️ 废单排除：WHERE is_trash = false。
    ⚠️ DQ-6：客户姓名通过 member_id JOIN dim_member。
    """

def get_service_records_for_task(
    conn, site_id: int, assistant_id: int, member_id: int, limit: int
) -> list[dict]:
    """查询特定客户的服务记录（TASK-2 用）。"""
```

### 组件 4：task_manager 服务扩展

**位置**：`apps/backend/app/services/task_manager.py`

**新增函数**：

```python
async def get_task_list_v2(
    user_id: int, site_id: int,
    status: str, page: int, page_size: int
) -> dict:
    """
    扩展版任务列表（TASK-1）。
    返回 { items, total, page, pageSize, performance }。
    """

async def get_task_detail(
    task_id: int, user_id: int, site_id: int
) -> dict:
    """
    任务详情完整版（TASK-2）。
    返回基础信息 + retentionClues + talkingPoints + serviceSummary
    + serviceRecords + aiAnalysis + notes + customerId。
    """
```

**`get_task_list_v2` 逻辑**：
1. `_get_assistant_id()` 获取 `assistant_id`
2. 查询 `biz.coach_tasks` 带分页（`LIMIT/OFFSET` + `COUNT(*)`）
3. 调用 `fdw_queries` 批量获取会员信息、余额、lastVisitDays
4. 调用 `fdw_queries.get_salary_calc()` 获取绩效概览
5. 查询 `biz.ai_cache` 获取 aiSuggestion
6. 组装 `TaskListResponse`

**`get_task_detail` 逻辑**：
1. `_get_assistant_id()` + `_verify_task_ownership()` 权限校验
2. 查询 `biz.coach_tasks` 基础信息
3. 查询 `public.member_retention_clue` 维客线索
4. 查询 `biz.ai_cache`（`app5_talking_points` → talkingPoints，`app4_analysis` → aiAnalysis）
5. 调用 `fdw_queries.get_service_records_for_task()` 获取服务记录（最多 20 条）
6. 查询 `biz.notes` 获取备注（最多 20 条）
7. 组装 `TaskDetailResponse`

### 组件 5：performance_service（新增）

**位置**：`apps/backend/app/services/performance_service.py`

**核心函数**：

```python
async def get_overview(
    user_id: int, site_id: int, year: int, month: int
) -> dict:
    """
    绩效概览（PERF-1）。
    1. 获取 assistant_id
    2. fdw_queries.get_salary_calc() → 档位/收入/费率
    3. fdw_queries.get_service_records() → 按日期分组为 DateGroup
    4. 聚合新客/常客列表
    5. 计算 incomeItems（含 desc 费率描述）
    6. 查询上月收入 lastMonthIncome
    """

async def get_records(
    user_id: int, site_id: int,
    year: int, month: int, page: int, page_size: int
) -> dict:
    """
    绩效明细（PERF-2）。
    1. 获取 assistant_id
    2. fdw_queries.get_service_records() 带分页
    3. 按日期分组为 dateGroups
    4. 计算 summary 汇总
    5. 返回 { summary, dateGroups, hasMore }
    """
```

### 组件 6：Pydantic Schema（新增/扩展）

**位置**：`apps/backend/app/schemas/xcx_tasks.py`（扩展）+ `apps/backend/app/schemas/xcx_performance.py`（新增）

#### 任务相关 Schema 扩展

```python
class PerformanceSummary(CamelModel):
    """绩效概览（附带在任务列表响应中）。"""
    total_hours: float
    total_income: float
    total_customers: int
    month_label: str
    tier_nodes: list[float]
    basic_hours: float
    bonus_hours: float
    current_tier: int
    next_tier_hours: float
    tier_completed: bool
    bonus_money: float
    income_trend: str
    income_trend_dir: str  # 'up' | 'down'
    prev_month: str
    current_tier_label: str

class TaskItem(CamelModel):
    """任务列表项（扩展版）。"""
    id: int
    customer_name: str
    customer_avatar: str
    task_type: str
    task_type_label: str
    deadline: str | None
    heart_score: float
    hobbies: list[str]
    is_pinned: bool
    has_note: bool
    status: str
    last_visit_days: int | None = None
    balance: float | None = None
    ai_suggestion: str | None = None

class TaskListResponse(CamelModel):
    """TASK-1 响应。"""
    items: list[TaskItem]
    total: int
    page: int
    page_size: int
    performance: PerformanceSummary
```

#### 任务详情 Schema

```python
class RetentionClue(CamelModel):
    tag: str
    tag_color: str
    emoji: str
    text: str
    source: str  # 'manual' | 'ai_consumption' | 'ai_note'
    desc: str | None = None

class ServiceRecord(CamelModel):
    table: str | None = None
    type: str
    type_class: str  # 'basic' | 'vip' | 'tip' | 'recharge' | 'incentive'
    record_type: str | None = None  # 'course' | 'recharge'
    duration: float
    duration_raw: float | None = None
    income: float
    is_estimate: bool | None = None
    drinks: str | None = None
    date: str

class AiAnalysis(CamelModel):
    summary: str
    suggestions: list[str]

class NoteItem(CamelModel):
    id: int
    content: str
    tag_type: str
    tag_label: str
    created_at: str
    score: int | None = None

class ServiceSummary(CamelModel):
    total_hours: float
    total_income: float
    avg_income: float

class TaskDetailResponse(CamelModel):
    """TASK-2 响应。"""
    # 基础信息
    id: int
    customer_name: str
    customer_avatar: str
    task_type: str
    task_type_label: str
    deadline: str | None
    heart_score: float
    hobbies: list[str]
    is_pinned: bool
    has_note: bool
    status: str
    customer_id: int
    # 扩展模块
    retention_clues: list[RetentionClue]
    talking_points: list[str]
    service_summary: ServiceSummary
    service_records: list[ServiceRecord]
    ai_analysis: AiAnalysis
    notes: list[NoteItem]
```

#### 绩效 Schema

```python
class DateGroupRecord(CamelModel):
    customer_name: str
    avatar_char: str | None = None   # PERF-1 返回，PERF-2 不返回
    avatar_color: str | None = None  # PERF-1 返回，PERF-2 不返回
    time_range: str
    hours: str
    course_type: str
    course_type_class: str  # 'basic' | 'vip' | 'tip'
    location: str
    income: str

class DateGroup(CamelModel):
    date: str
    total_hours: str
    total_income: str
    records: list[DateGroupRecord]

class TierInfo(CamelModel):
    basic_rate: float
    incentive_rate: float

class IncomeItem(CamelModel):
    icon: str
    label: str
    desc: str
    value: str

class CustomerSummary(CamelModel):
    name: str
    avatar_char: str
    avatar_color: str

class NewCustomer(CustomerSummary):
    last_service: str
    count: int

class RegularCustomer(CustomerSummary):
    hours: float
    income: str
    count: int

class PerformanceOverviewResponse(CamelModel):
    """PERF-1 响应。"""
    coach_name: str
    coach_role: str
    store_name: str
    monthly_income: str
    last_month_income: str
    current_tier: TierInfo
    next_tier: TierInfo
    upgrade_hours_needed: float
    upgrade_bonus: float
    income_items: list[IncomeItem]
    monthly_total: str
    this_month_records: list[DateGroup]
    new_customers: list[NewCustomer]
    regular_customers: list[RegularCustomer]

class RecordsSummary(CamelModel):
    total_count: int
    total_hours: float
    total_hours_raw: float
    total_income: float

class PerformanceRecordsResponse(CamelModel):
    """PERF-2 响应。"""
    summary: RecordsSummary
    date_groups: list[DateGroup]
    has_more: bool
```

### 组件 7：前端适配

**涉及文件与改动**：

| 文件 | 改动 |
|------|------|
| `services/api.ts` | 新增 `pinTask()`、`unpinTask()`；`createNote()` 增加 `score` 参数；新增 `fetchTaskDetail()`、`fetchPerformanceOverview(year, month)`、`fetchPerformanceRecords(year, month, page)` |
| `pages/task-list/task-list.ts` | 消费 `TaskListResponse` 新结构；`buildPerfData()` 使用 15+ 字段绩效数据 |
| `pages/task-detail/task-detail.ts` | 调用 `fetchTaskDetail()`；`storageLevel`/`relationLevel` 前端本地计算 |
| `pages/performance/performance.ts` | 添加月份切换控件；切换时重新调用 API |
| `pages/performance-records/performance-records.ts` | 月份切换时重置 `page=1`；`avatarChar`/`avatarColor` 使用 `nameToAvatarColor()` 前端计算 |



## 数据模型

### FDW 查询模式

所有 FDW 查询遵循统一模式：

```python
# 1. 使用业务库连接（get_connection() → zqyy_app）
conn = get_connection()

# 2. 开启事务 + 设置门店隔离
with conn.cursor() as cur:
    cur.execute("BEGIN")
    cur.execute("SET LOCAL app.current_site_id = %s", (str(site_id),))

    # 3. 通过 fdw_etl schema 访问 ETL 视图
    cur.execute("""
        SELECT ...
        FROM fdw_etl.v_dws_assistant_salary_calc
        WHERE assistant_id = %s AND calc_month = %s
    """, (assistant_id, f"{year}-{month:02d}"))

conn.commit()
```

### 核心 SQL 查询设计

#### Q1: 绩效概览查询（TASK-1 performance + PERF-1）

```sql
-- ⚠️ DWD-DOC 规则 1: 收入使用 items_sum 口径
-- ⚠️ DWD-DOC 规则 2: 费用使用 assistant_pd_money + assistant_cx_money
SELECT
    calc_month,
    coach_level,                          -- 当前档位名称
    tier_index,                           -- 当前档位索引
    tier_nodes,                           -- 档位节点数组 (JSON)
    basic_hours,                          -- 基础课时 (assistant_pd_money 对应)
    bonus_hours,                          -- 激励课时 (assistant_cx_money 对应)
    total_hours,                          -- 总工时
    total_income,                         -- 总收入 (items_sum 口径)
    total_customers,                      -- 服务客户数
    basic_rate,                           -- 基础课时费率
    incentive_rate,                       -- 激励课时费率
    next_tier_basic_rate,                 -- 下一档基础课时费率
    next_tier_incentive_rate,             -- 下一档激励课时费率
    next_tier_hours,                      -- 下一档工时阈值
    tier_completed,                       -- 是否已达最高档
    bonus_money,                          -- 升档奖金
    assistant_pd_money_total,             -- 基础课总费用
    assistant_cx_money_total              -- 激励课总费用
FROM fdw_etl.v_dws_assistant_salary_calc
WHERE assistant_id = %s
  AND calc_month = %s
```

#### Q2: 收入趋势计算（TASK-1 performance）

```sql
-- 当月 vs 上月收入对比
WITH months AS (
    SELECT total_income, calc_month
    FROM fdw_etl.v_dws_assistant_salary_calc
    WHERE assistant_id = %s
      AND calc_month IN (%s, %s)  -- 当月, 上月
)
SELECT * FROM months ORDER BY calc_month
```

后端计算逻辑：
```python
diff = current_income - prev_income
income_trend = f"{'↑' if diff >= 0 else '↓'}{abs(diff):.0f}"
income_trend_dir = "up" if diff >= 0 else "down"
```

#### Q3: 服务记录查询（PERF-1 thisMonthRecords / PERF-2 dateGroups / TASK-2 serviceRecords）

```sql
-- ⚠️ DWD-DOC 规则 DQ-6: 客户姓名通过 member_id JOIN dim_member
-- ⚠️ 废单排除: is_trash = false
SELECT
    sl.id,
    dm.nickname AS customer_name,
    sl.settle_time,
    sl.start_time,
    sl.end_time,
    sl.service_hours,                     -- 折算工时
    sl.service_hours_raw,                 -- 原始工时
    sl.course_type,                       -- 课程类型
    sl.table_name,                        -- 台桌名
    sl.items_sum AS income,               -- ⚠️ items_sum 口径
    sl.is_estimate
FROM fdw_etl.v_dwd_assistant_service_log sl
LEFT JOIN fdw_etl.v_dim_member dm
    ON sl.member_id = dm.member_id
    AND dm.scd2_is_current = 1            -- ⚠️ SCD2 当前记录
WHERE sl.assistant_id = %s
  AND sl.is_trash = false                 -- ⚠️ 废单排除
  AND sl.settle_time >= %s                -- 月份起始
  AND sl.settle_time < %s                 -- 月份结束
ORDER BY sl.settle_time DESC
LIMIT %s OFFSET %s
```

#### Q4: 任务项扩展字段查询

```sql
-- lastVisitDays: 距上次到店天数
SELECT member_id,
       CURRENT_DATE - MAX(settle_time::date) AS days_since_visit
FROM fdw_etl.v_dwd_assistant_service_log
WHERE member_id = ANY(%s)
  AND is_trash = false
GROUP BY member_id

-- balance: 客户储值卡余额
-- ⚠️ DQ-7: 通过 member_id JOIN dim_member_card_account
SELECT tenant_member_id AS member_id,
       balance
FROM fdw_etl.v_dim_member_card_account
WHERE tenant_member_id = ANY(%s)
  AND scd2_is_current = 1
```

#### Q5: 维客线索查询（TASK-2）

```sql
SELECT id, tag, tag_color, emoji, text, source, description
FROM public.member_retention_clue
WHERE member_id = %s
  AND site_id = %s
ORDER BY created_at DESC
```

#### Q6: AI 缓存查询（TASK-2）

```sql
-- aiAnalysis: cache_type = 'app4_analysis'
-- talkingPoints: cache_type = 'app5_talking_points'
SELECT cache_type, cache_value
FROM biz.ai_cache
WHERE target_id = %s
  AND site_id = %s
  AND cache_type IN ('app4_analysis', 'app5_talking_points')
```

#### Q7: 新客/常客列表（PERF-1）

```sql
-- 新客：本月首次服务的客户
-- ⚠️ DQ-6: 客户姓名通过 member_id JOIN dim_member
WITH month_records AS (
    SELECT sl.member_id,
           dm.nickname AS customer_name,
           COUNT(*) AS service_count,
           MAX(sl.settle_time) AS last_service
    FROM fdw_etl.v_dwd_assistant_service_log sl
    LEFT JOIN fdw_etl.v_dim_member dm
        ON sl.member_id = dm.member_id AND dm.scd2_is_current = 1
    WHERE sl.assistant_id = %s
      AND sl.is_trash = false
      AND sl.settle_time >= %s AND sl.settle_time < %s
    GROUP BY sl.member_id, dm.nickname
),
historical AS (
    SELECT DISTINCT member_id
    FROM fdw_etl.v_dwd_assistant_service_log
    WHERE assistant_id = %s
      AND is_trash = false
      AND settle_time < %s  -- 本月之前
)
SELECT mr.member_id, mr.customer_name, mr.service_count, mr.last_service
FROM month_records mr
LEFT JOIN historical h ON mr.member_id = h.member_id
WHERE h.member_id IS NULL  -- 新客：历史无记录
ORDER BY mr.last_service DESC

-- 常客：本月服务 ≥ 2 次的客户
SELECT mr.member_id, mr.customer_name,
       SUM(sl.service_hours) AS total_hours,
       SUM(sl.items_sum) AS total_income,  -- ⚠️ items_sum 口径
       COUNT(*) AS service_count
FROM month_records mr
JOIN fdw_etl.v_dwd_assistant_service_log sl
    ON mr.member_id = sl.member_id
WHERE mr.service_count >= 2
GROUP BY mr.member_id, mr.customer_name
ORDER BY total_income DESC
```

### courseTypeClass 枚举映射

后端根据 `v_dwd_assistant_service_log.course_type` 映射：

| 原始值 | courseTypeClass | courseType 中文 |
|--------|----------------|----------------|
| `basic` / `陪打` / `基础课` | `basic` | 基础课 |
| `vip` / `包厢` / `包厢课` | `vip` | 包厢课 |
| `tip` / `超休` / `激励课` | `tip` | 激励课 |
| `recharge` / `充值` | `recharge` | 充值 |
| `incentive` / `激励` | `incentive` | 激励 |

统一不带 `tag-` 前缀（契约要求）。

### DWD-DOC 强制规则实施位置

| 规则 | 实施位置 | 具体措施 |
|------|---------|---------|
| 规则 1: `items_sum` 口径 | `fdw_queries.py` 所有金额查询 | SQL 中使用 `items_sum` 字段，禁止 `consume_money` |
| 规则 2: 助教费用拆分 | `fdw_queries.get_salary_calc()` | 使用 `assistant_pd_money` + `assistant_cx_money`，禁止 `service_fee` |
| DQ-6: 会员信息 JOIN | `fdw_queries.get_member_info()` + 所有含客户姓名的查询 | `member_id JOIN fdw_etl.v_dim_member WHERE scd2_is_current=1` |
| DQ-7: 会员卡 JOIN | `fdw_queries.get_member_balance()` | `member_id JOIN fdw_etl.v_dim_member_card_account WHERE scd2_is_current=1` |
| 废单排除 | `fdw_queries` 所有服务记录查询 | `WHERE is_trash = false`，禁止引用 `dwd_assistant_trash_event` |



## 正确性属性

*属性（Property）是一个在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*

### Property 1: 收入趋势计算正确性

*For any* 两个非负收入值 `currentIncome` 和 `prevIncome`，计算 `incomeTrend` 和 `incomeTrendDir` 时：
- `incomeTrendDir` 应为 `"up"` 当 `currentIncome >= prevIncome`，否则为 `"down"`
- `incomeTrend` 应包含绝对差值 `abs(currentIncome - prevIncome)` 的整数表示
- `incomeTrend` 应以 `"↑"` 或 `"↓"` 开头，与 `incomeTrendDir` 一致

**Validates: Requirements 1.4**

### Property 2: lastVisitDays 计算正确性

*For any* 有效日期 `lastVisitDate`（不晚于今天），`lastVisitDays` 应等于 `(current_date - lastVisitDate).days`，且结果为非负整数。

**Validates: Requirements 1.7**

### Property 3: 维客线索 tag 净化与 source 枚举

*For any* 维客线索记录，经过后端处理后：
- `tag` 字段不应包含换行符 `\n`（多行标签使用空格分隔）
- `source` 字段必须是 `manual`、`ai_consumption`、`ai_note` 三者之一

**Validates: Requirements 2.4, 2.5**

### Property 4: courseTypeClass 枚举映射

*For any* 服务记录的原始课程类型值，经过 `map_course_type_class()` 映射后：
- 结果必须是 `basic`、`vip`、`tip`、`recharge`、`incentive` 五者之一
- 结果不应包含 `tag-` 前缀

**Validates: Requirements 2.9, 4.4**

### Property 5: 列表分页与排序

*For any* 记录集合和分页参数 `(page, pageSize)`：
- 返回的记录数 ≤ `pageSize`
- `hasMore` 为 `true` 当且仅当总记录数 > `page * pageSize`
- 服务记录在每个 DateGroup 内按时间倒序排列
- 备注列表按 `created_at` 倒序排列

**Validates: Requirements 2.3, 4.2**

### Property 6: DateGroup 分组正确性

*For any* 服务记录集合，按日期分组后：
- 每个 DateGroup 的 `date` 在结果中唯一
- 每个 DateGroup 内的所有记录的日期部分相同
- `totalHours` 等于该组内所有记录 `hours` 之和
- `totalIncome` 等于该组内所有记录 `income` 之和
- DateGroup 列表按日期倒序排列

**Validates: Requirements 3.3, 4.3**

### Property 7: incomeItems desc 格式化

*For any* 费率 `rate`（正数）和工时 `hours`（非负数），生成的 `desc` 字段应包含费率值和工时值，格式为 `"{rate}元/h × {hours}h"`。

**Validates: Requirements 3.9**

### Property 8: 月度汇总聚合正确性

*For any* 服务记录集合，`summary` 的聚合应满足：
- `totalCount` 等于记录总数
- `totalHours` 等于所有记录折算工时之和
- `totalHoursRaw` 等于所有记录原始工时之和
- `totalIncome` 等于所有记录收入之和（`items_sum` 口径）

**Validates: Requirements 4.6**

### Property 9: Pin/Unpin 状态往返

*For any* 有效任务，执行 `pin` 后 `isPinned` 应为 `true`，再执行 `unpin` 后 `isPinned` 应恢复为 `false`。即 `unpin(pin(task))` 的 `isPinned` 状态等于原始状态（假设原始为 `false`）。

**Validates: Requirements 5.1, 5.2, 5.3**

### Property 10: 权限与数据隔离

*For any* 请求，若用户状态非 `approved`、缺少 `view_tasks` 权限、或在 `user_assistant_binding` 中无绑定记录，则所有 RNS1.1 端点应返回 HTTP 403。若请求的 `taskId` 不属于当前助教的 `assistant_id`，也应返回 HTTP 403。

**Validates: Requirements 2.13, 8.1, 8.2, 8.4**

### Property 11: 前端派生字段计算

*For any* 客户姓名字符串 `name`（非空），`nameToAvatarColor(name)` 应返回：
- `avatarChar` 等于 `name` 的第一个字符
- `avatarColor` 为预定义颜色集合中的一个值
- 相同 `name` 输入始终产生相同输出（确定性）

*For any* 余额值 `balance`（非负数），`computeStorageLevel(balance)` 应返回预定义等级之一（如 "非常多"/"较多"/"一般"/"较少"），且等级随余额单调递增。

*For any* 亲密度分数 `heartScore`（0-10），`computeRelationLevel(heartScore)` 应返回预定义等级之一，且等级随分数单调递增。

**Validates: Requirements 6.4, 6.5, 7.6**

### Property 12: 备注 score 输入验证

*For any* 整数 `score`，若 `score` 在 1-5 范围内，`POST /api/xcx/notes` 应接受并存储；若 `score` 超出范围（<1 或 >5），应返回 422 错误。`score` 为 `null` 时应正常创建备注（score 可选）。

**Validates: Requirements 6.3**

## 错误处理

### 后端错误处理

| 错误场景 | HTTP 状态码 | 响应 | 触发位置 |
|---------|:----------:|------|---------|
| 用户未通过审核 | 403 | `{ code: 403, message: "用户未通过审核，无法访问此资源" }` | `require_approved()` |
| 用户无 `view_tasks` 权限 | 403 | `{ code: 403, message: "权限不足" }` | `require_permission("view_tasks")` |
| 用户未绑定助教身份 | 403 | `{ code: 403, message: "未绑定助教身份" }` | `_get_assistant_id()` |
| 任务不属于当前助教 | 403 | `{ code: 403, message: "无权访问该任务" }` | `_verify_task_ownership()` |
| 任务不存在 | 404 | `{ code: 404, message: "任务不存在" }` | `get_task_detail()` / `pin_task()` |
| score 超出 1-5 范围 | 422 | `{ code: 422, message: "评分必须在 1-5 范围内" }` | `create_note()` |
| 无效的 status 参数 | 422 | Pydantic 验证错误 | FastAPI 参数校验 |
| year/month 参数无效 | 422 | Pydantic 验证错误 | FastAPI 参数校验 |
| FDW 查询超时/失败 | 500 | `{ code: 500, message: "Internal Server Error" }` | `unhandled_exception_handler` |
| 数据库连接失败 | 500 | `{ code: 500, message: "Internal Server Error" }` | `unhandled_exception_handler` |

### FDW 查询容错策略

任务列表扩展字段（`lastVisitDays`、`balance`、`aiSuggestion`）采用**优雅降级**策略：

```python
# 单个扩展字段查询失败不影响整体响应
try:
    last_visit_map = fdw_queries.get_last_visit_days(conn, site_id, member_ids)
except Exception:
    logger.warning("FDW 查询 lastVisitDays 失败", exc_info=True)
    last_visit_map = {}
```

核心字段（绩效概览、服务记录）查询失败则直接抛出 500。

### 前端错误处理

| 场景 | 处理方式 |
|------|---------|
| API 返回 403 | 显示"权限不足"提示，不跳转 |
| API 返回 404 | 显示"任务不存在"提示，返回列表页 |
| API 返回 500 | 显示"服务器错误"toast |
| 月份切换期间网络失败 | 恢复上一月份状态，显示重试提示 |
| 扩展字段为 null | 前端隐藏对应 UI 元素（如不显示余额标签） |

## 测试策略

### 双轨测试方法

RNS1.1 采用属性测试（Property-Based Testing）+ 单元测试（Unit Testing）双轨并行：

- **属性测试**：验证计算逻辑、枚举映射、分页排序、聚合等通用规则在所有输入上的正确性
- **单元测试**：验证具体的 API 端点行为、权限校验、FDW 查询集成、边界条件

### 属性测试配置

- **测试库**：[Hypothesis](https://hypothesis.readthedocs.io/)（Python，项目已使用）
- **测试位置**：`tests/` 目录（Monorepo 级属性测试）
- **最小迭代次数**：每个属性测试 100 次（`@settings(max_examples=100)`）
- **标签格式**：每个测试函数的 docstring 中标注 `Feature: rns1-task-performance-api, Property {N}: {property_text}`
- **每个正确性属性由一个属性测试实现**

### 属性测试清单

| Property | 测试函数 | 生成器 | 验证逻辑 |
|----------|---------|--------|---------|
| P1: 收入趋势计算 | `test_income_trend_computation` | `st.floats(min_value=0, max_value=1e6)` × 2 | 方向正确、差值正确、前缀符号一致 |
| P2: lastVisitDays 计算 | `test_last_visit_days_computation` | `st.dates(max_value=date.today())` | 天数差 = (today - date).days，非负 |
| P3: 维客线索净化 | `test_retention_clue_sanitization` | `st.text()` 生成含 `\n` 的 tag + `st.sampled_from` source | tag 无换行、source 在枚举内 |
| P4: courseTypeClass 映射 | `test_course_type_class_mapping` | `st.sampled_from(ALL_COURSE_TYPES)` | 结果在枚举内、无 `tag-` 前缀 |
| P5: 分页与排序 | `test_pagination_and_ordering` | `st.lists(st.fixed_dictionaries(...))` + `st.integers(1,10)` page/pageSize | 记录数 ≤ pageSize、hasMore 正确、排序正确 |
| P6: DateGroup 分组 | `test_date_group_correctness` | `st.lists(st.fixed_dictionaries({date, hours, income}))` | 日期唯一、组内日期一致、汇总正确 |
| P7: incomeItems desc 格式化 | `test_income_item_desc_format` | `st.floats(min_value=0.01)` rate × `st.floats(min_value=0)` hours | 包含费率和工时值 |
| P8: 月度汇总聚合 | `test_monthly_summary_aggregation` | `st.lists(st.fixed_dictionaries({hours, hours_raw, income}))` | count/hours/hours_raw/income 聚合正确 |
| P9: Pin/Unpin 往返 | `test_pin_unpin_roundtrip` | `st.booleans()` 初始状态 | pin→true、unpin→false、往返恢复 |
| P10: 权限隔离 | `test_authorization_enforcement` | `st.sampled_from(INVALID_USER_SCENARIOS)` | 所有场景返回 403 |
| P11: 前端派生字段 | `test_frontend_derived_fields` | `st.text(min_size=1)` name + `st.floats(0, 1e6)` balance + `st.floats(0, 10)` heartScore | avatarChar=name[0]、确定性、单调性 |
| P12: score 输入验证 | `test_note_score_validation` | `st.integers()` | 1-5 接受、超范围拒绝、null 接受 |

### 单元测试清单

| 测试目标 | 测试文件 | 关键用例 |
|---------|---------|---------|
| TASK-1 端点 | `tests/unit/test_xcx_tasks_v2.py` | 正常返回 TaskListResponse 结构；performance 含 15+ 字段；扩展字段 null 降级 |
| TASK-2 端点 | `tests/unit/test_task_detail.py` | 完整详情返回；customerId 正确；serviceRecords ≤ 20 条；notes ≤ 20 条 |
| PERF-1 端点 | `tests/unit/test_performance_overview.py` | DateGroup 结构正确；收入档位数据完整；新客/常客列表 |
| PERF-2 端点 | `tests/unit/test_performance_records.py` | 分页正确；summary 聚合正确；hasMore 标记 |
| PIN/UNPIN | `tests/unit/test_pin_unpin.py` | 响应格式 `{ isPinned: bool }`；404 不存在；403 非本人任务 |
| 权限校验 | `tests/unit/test_auth_rns11.py` | 未审核用户 403；无绑定 403；无权限 403 |
| FDW 查询 | `tests/unit/test_fdw_queries.py` | DQ-6 JOIN 正确；is_trash 排除；items_sum 口径 |
| AI 缓存降级 | `tests/unit/test_ai_cache_fallback.py` | 无缓存返回空；cache_type 映射正确 |
| tierCompleted 边界 | `tests/unit/test_tier_completed.py` | tierCompleted=true 时 bonusMoney=0 |

### 测试执行命令

```bash
# 属性测试（Hypothesis）
cd C:\Project\NeoZQYY && pytest tests/ -v -k "rns1_task_performance"

# 单元测试
cd apps/backend && pytest tests/unit/ -v -k "xcx_tasks_v2 or task_detail or performance or pin_unpin or auth_rns11 or fdw_queries"
```
