# 技术设计文档 — RNS1.2：客户与助教接口

## 概述

RNS1.2 实现客户详情（CUST-1）、客户服务记录（CUST-2）、助教详情（COACH-1）3 个接口，覆盖 6 个任务：

1. **T2-1 CUST-1 客户详情**：Banner 概览 + AI 洞察 + 维客线索 + 消费记录嵌套结构 + 备注
2. **T2-2 CUST-1 coachTasks**：关联助教任务列表（近 60 天统计）
3. **T2-3 CUST-1 favoriteCoaches**：最亲密助教（关系指数排序）
4. **T2-4 CUST-2 客户服务记录**：按月查询 + 月度统计汇总
5. **T2-5 COACH-1 助教详情**：绩效 + 收入 + 档位 + TOP 客户 + 任务分组 + 备注
6. **T2-6 COACH-1 historyMonths**：历史月份统计（最近 5+ 个月）

### 设计原则

- **增量扩展**：在现有 `fdw_queries.py` 基础上新增查询方法，新建 `customer_service.py` 和 `coach_service.py`
- **FDW 查询集中化**：所有 ETL 查询封装在 `fdw_queries.py`，路由层不直接操作数据库
- **DWD-DOC 强制规则**：金额 `items_sum` 口径、助教费用 `assistant_pd_money` + `assistant_cx_money` 拆分、会员信息通过 `member_id` JOIN `v_dim_member`
- **契约驱动**：响应结构严格遵循 `API-contract.md` 中 CUST-1、CUST-2、COACH-1 定义
- **优雅降级**：扩展模块查询失败返回空默认值，不影响核心响应

### 依赖

- RNS1.0 已完成：`ResponseWrapperMiddleware`、`CamelModel`、前端 `request()` 解包
- 现有代码：`fdw_queries.py`（已有 6 个查询函数）、`task_manager.py`、`note_service.py`
- RNS1.1 可并行：无直接依赖

## 架构

### 模块交互

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

    subgraph "FastAPI 后端"
        E[routers/xcx_customers.py<br/>CUST-1, CUST-2 — 新增]
        F[routers/xcx_coaches.py<br/>COACH-1 — 新增]
        G[services/customer_service.py<br/>客户查询 — 新增]
        H[services/coach_service.py<br/>助教查询 — 新增]
        I[services/fdw_queries.py<br/>FDW 查询封装 — 扩展]
        J[services/note_service.py<br/>备注查询 — 已有]
    end

    subgraph "数据库"
        K[(zqyy_app<br/>biz.coach_tasks<br/>biz.ai_cache<br/>biz.notes<br/>public.member_retention_clue)]
        L[(etl_feiqiu 直连<br/>app.v_dwd_assistant_service_log<br/>app.v_dws_assistant_salary_calc<br/>app.v_dws_member_assistant_relation_index<br/>app.v_dws_member_consumption_summary<br/>app.v_dim_member<br/>app.v_dim_member_card_account<br/>app.v_dim_assistant<br/>app.v_dwd_table_fee_log)]
    end

    B -->|HTTP JSON| E
    B -->|HTTP JSON| F
    E --> G
    F --> H
    G --> I
    H --> I
    G --> K
    H --> K
    G --> J
    H --> J
    I -->|直连 ETL + SET LOCAL| L

    style E fill:#9f9,stroke:#333
    style F fill:#9f9,stroke:#333
    style G fill:#9f9,stroke:#333
    style H fill:#9f9,stroke:#333
```

### 请求流程（以 CUST-1 为例）

```mermaid
sequenceDiagram
    participant MP as 小程序
    participant R as xcx_customers Router
    participant CS as customer_service
    participant FDW as fdw_queries
    participant BIZ as zqyy_app
    participant ETL as etl_feiqiu 直连

    MP->>R: GET /api/xcx/customers/{customerId}
    R->>CS: get_customer_detail(customerId, site_id)

    par 基础信息 + Banner
        CS->>FDW: get_member_info([customerId])
        FDW->>ETL: SET LOCAL + SELECT v_dim_member
        CS->>FDW: get_member_balance([customerId])
        FDW->>ETL: SELECT v_dim_member_card_account
        CS->>FDW: get_last_visit_days([customerId])
        FDW->>ETL: SELECT v_dwd_assistant_service_log
        CS->>FDW: get_consumption_60d(customerId)
        FDW->>ETL: SELECT v_dws_member_consumption_summary
    end

    par 扩展模块（优雅降级）
        CS->>BIZ: SELECT ai_cache (aiInsight)
        CS->>BIZ: SELECT member_retention_clue (retentionClues)
        CS->>BIZ: SELECT notes (备注)
        CS->>FDW: get_consumption_records(customerId)
        FDW->>ETL: SELECT v_dwd_assistant_service_log + JOIN
        CS->>BIZ: SELECT coach_tasks (coachTasks)
        CS->>FDW: get_favorite_coaches(customerId)
        FDW->>ETL: SELECT v_dws_member_assistant_relation_index
    end

    CS-->>R: CustomerDetailResponse
    R-->>MP: { code: 0, data: CustomerDetailResponse }
```

## 组件与接口

### 组件 1：xcx_customers Router（新增）

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

**职责**：客户详情和客户服务记录两个端点。

```python
router = APIRouter(prefix="/api/xcx/customers", tags=["小程序客户"])

@router.get("/{customer_id}", response_model=CustomerDetailResponse)
async def get_customer_detail(
    customer_id: int,
    user: CurrentUser = Depends(require_approved()),
):
    """客户详情（CUST-1）。"""
    return await customer_service.get_customer_detail(
        customer_id, user.site_id
    )

@router.get("/{customer_id}/records", response_model=CustomerRecordsResponse)
async def get_customer_records(
    customer_id: int,
    year: int = Query(...),
    month: int = Query(..., ge=1, le=12),
    table: str | None = Query(None),
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=100),
    user: CurrentUser = Depends(require_approved()),
):
    """客户服务记录（CUST-2）。"""
    return await customer_service.get_customer_records(
        customer_id, user.site_id, year, month, table, page, page_size
    )
```

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

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

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

**职责**：助教详情端点。

```python
router = APIRouter(prefix="/api/xcx/coaches", tags=["小程序助教"])

@router.get("/{coach_id}", response_model=CoachDetailResponse)
async def get_coach_detail(
    coach_id: int,
    user: CurrentUser = Depends(require_approved()),
):
    """助教详情（COACH-1）。"""
    return await coach_service.get_coach_detail(
        coach_id, user.site_id
    )
```

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

### 组件 3：customer_service（新增）

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

**核心函数**：

```python
async def get_customer_detail(customer_id: int, site_id: int) -> dict:
    """
    客户详情（CUST-1）。
    1. fdw_queries.get_member_info() → 基础信息（name/phone/avatar/memberLevel）
    2. fdw_queries.get_member_balance() → balance
    3. fdw_queries.get_consumption_60d() → consumption60d
    4. fdw_queries.get_last_visit_days() → daysSinceVisit
    5. biz.ai_cache → aiInsight（cache_type='app4_analysis'）
    6. public.member_retention_clue → retentionClues
    7. biz.coach_tasks + FDW 聚合 → coachTasks
    8. fdw_queries.get_favorite_coaches() → favoriteCoaches
    9. fdw_queries.get_consumption_records() → consumptionRecords
    10. biz.notes → notes（target_type='member'，最多 20 条）
    扩展模块（5-10）查询失败返回空默认值。
    """

async def get_customer_records(
    customer_id: int, site_id: int,
    year: int, month: int,
    table: str | None,
    page: int, page_size: int,
) -> dict:
    """
    客户服务记录（CUST-2）。
    1. fdw_queries.get_member_info() → customerName/customerPhone
    2. fdw_queries.get_customer_service_records() → 按月分页记录
    3. 聚合 monthCount/monthHours
    4. fdw_queries.get_total_service_count() → totalServiceCount（跨月）
    5. 返回 { customerName, records, monthCount, monthHours, totalServiceCount, hasMore }
    """

def _build_coach_tasks(customer_id: int, site_id: int, conn) -> list[dict]:
    """
    构建 coachTasks 模块。
    1. 查询 biz.coach_tasks WHERE member_id=customer_id
    2. 对每位助教：fdw_queries 获取等级、近 60 天统计
    3. 映射 levelColor/taskColor/bgClass
    """

def _build_favorite_coaches(customer_id: int, site_id: int, conn) -> list[dict]:
    """
    构建 favoriteCoaches 模块。
    1. fdw_queries.get_relation_index() → 关系指数列表
    2. 按关系指数降序排列
    3. 映射 emoji（≥0.7→💖，<0.7→💛）、indexColor、bgClass
    4. fdw_queries 聚合 stats（基础课时/激励课时/上课次数/充值金额）
    """

def _build_consumption_records(customer_id: int, site_id: int, conn) -> list[dict]:
    """
    构建 consumptionRecords 模块。
    1. fdw_queries.get_consumption_records() → 结算单列表
    2. 对每条结算单：关联 v_dwd_assistant_service_log 获取 coaches 子数组
    3. coaches[].fee 使用 assistant_pd_money / assistant_cx_money 拆分
    4. totalAmount 使用 items_sum 口径
    5. 过滤 settle_type IN (1, 3)，排除 is_delete != 0
    """
```

### 组件 4：coach_service（新增）

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

**核心函数**：

```python
async def get_coach_detail(coach_id: int, site_id: int) -> dict:
    """
    助教详情（COACH-1）。
    1. fdw_queries.get_assistant_info() → 基础信息
    2. fdw_queries.get_salary_calc() → 绩效/收入/档位（当月+上月）
    3. fdw_queries.get_member_balance() → customerBalance（该助教所有客户余额合计）
    4. biz.coach_tasks → tasksCompleted + 任务分组
    5. fdw_queries.get_top_customers() → topCustomers
    6. fdw_queries.get_service_records() → serviceRecords
    7. _build_history_months() → historyMonths
    8. biz.notes → notes（最多 20 条）
    扩展模块查询失败返回空默认值。
    """

def _build_income(salary_this: dict, salary_last: dict) -> dict:
    """
    构建 income 模块。
    thisMonth/lastMonth 各含 4 项：
    - 基础课时费（assistant_pd_money / base_income）
    - 激励课时费（assistant_cx_money / bonus_income）
    - 充值提成（从 salary_calc 获取）
    - 酒水提成（从 salary_calc 获取）
    """

def _build_task_groups(coach_id: int, site_id: int, conn) -> dict:
    """
    构建任务分组。
    1. 查询 biz.coach_tasks WHERE assistant_id=coach_id
    2. 按 status 分组：active→visibleTasks, inactive→hiddenTasks, abandoned→abandonedTasks
    3. 对 visible/hidden：关联 biz.notes 获取备注列表
    4. 对 abandoned：取 abandon_reason
    """

def _build_history_months(coach_id: int, site_id: int, conn) -> list[dict]:
    """
    构建 historyMonths 模块。
    1. fdw_queries.get_salary_calc_multi_months() → 最近 6 个月工时/工资
    2. fdw_queries.get_monthly_customer_count() → 各月客户数
    3. biz.coach_tasks → 各月回访/召回完成数
    4. 本月 estimated=True，历史月份 estimated=False
    5. 格式化：customers→"22人"，hours→"87.5h"，salary→"¥6,950"
    """

def _build_top_customers(coach_id: int, site_id: int, conn) -> list[dict]:
    """
    构建 topCustomers 模块（最多 20 条）。
    1. fdw_queries.get_coach_top_customers() → 按服务次数/消费排序
    2. fdw_queries.get_member_info() → 客户姓名（DQ-6）
    3. fdw_queries.get_member_balance() → 余额（DQ-7）
    4. 映射 heartEmoji（≥0.7→❤️，0.3-0.7→💛，<0.3→🤍）
    5. consume 使用 items_sum 口径
    """
```

### 组件 5：fdw_queries 扩展

**位置**：`apps/backend/app/services/fdw_queries.py`（在现有模块上新增函数）

**新增函数**：

```python
def get_consumption_60d(conn, site_id: int, member_id: int) -> Decimal | None:
    """
    查询客户近 60 天消费金额。
    来源：app.v_dwd_assistant_service_log。
    ⚠️ DWD-DOC 规则 1：使用 ledger_amount（items_sum 口径）。
    ⚠️ 废单排除：is_delete = 0。
    """

def get_relation_index(
    conn, site_id: int, member_id: int
) -> list[dict]:
    """
    查询客户与助教的关系指数列表。
    来源：app.v_dws_member_assistant_relation_index。
    返回 [{assistant_id, relation_index, ...}]，按 relation_index 降序。
    """

def get_consumption_records(
    conn, site_id: int, member_id: int, limit: int, offset: int
) -> list[dict]:
    """
    查询客户消费记录（嵌套结构用）。
    来源：app.v_dwd_assistant_service_log + v_dwd_table_fee_log。
    ⚠️ DWD-DOC 规则 1：totalAmount 使用 ledger_amount。
    ⚠️ DWD-DOC 规则 2：coaches fee 使用 assistant_pd_money / assistant_cx_money。
    ⚠️ 废单排除：is_delete = 0。
    ⚠️ 正向交易：settle_type IN (1, 3)。
    """

def get_customer_service_records(
    conn, site_id: int, member_id: int,
    year: int, month: int,
    table: str | None, limit: int, offset: int
) -> tuple[list[dict], int]:
    """
    查询客户按月服务记录（CUST-2 用）。
    来源：app.v_dwd_assistant_service_log。
    返回 (records, total_count)。
    ⚠️ DQ-6：助教姓名通过 site_assistant_id JOIN v_dim_assistant。
    ⚠️ 废单排除：is_delete = 0。
    """

def get_total_service_count(conn, site_id: int, member_id: int) -> int:
    """
    查询客户累计服务总次数（跨所有月份）。
    来源：app.v_dwd_assistant_service_log，COUNT WHERE is_delete = 0。
    """

def get_assistant_info(conn, site_id: int, assistant_id: int) -> dict | None:
    """
    查询助教基本信息。
    来源：app.v_dim_assistant。
    返回 {name, avatar, skills, work_years, hire_date}。
    """

def get_salary_calc_multi_months(
    conn, site_id: int, assistant_id: int, months: list[str]
) -> dict[str, dict]:
    """
    批量查询多个月份的绩效数据。
    来源：app.v_dws_assistant_salary_calc。
    返回 {month_str: salary_data}。
    """

def get_monthly_customer_count(
    conn, site_id: int, assistant_id: int, months: list[str]
) -> dict[str, int]:
    """
    批量查询各月不重复客户数。
    来源：app.v_dwd_assistant_service_log，COUNT(DISTINCT tenant_member_id)。
    ⚠️ 废单排除：is_delete = 0。
    """

def get_coach_top_customers(
    conn, site_id: int, assistant_id: int, limit: int = 20
) -> list[dict]:
    """
    查询助教 TOP 客户（按服务次数降序）。
    来源：app.v_dwd_assistant_service_log + v_dim_member + v_dim_member_card_account。
    ⚠️ DQ-6：客户姓名通过 tenant_member_id JOIN v_dim_member。
    ⚠️ DQ-7：余额通过 tenant_member_id JOIN v_dim_member_card_account。
    ⚠️ DWD-DOC 规则 1：consume 使用 ledger_amount（items_sum 口径）。
    ⚠️ 废单排除：is_delete = 0。
    """

def get_coach_60d_stats(
    conn, site_id: int, assistant_id: int, member_id: int
) -> dict:
    """
    查询特定助教对特定客户的近 60 天统计。
    返回 {service_count, total_hours, avg_hours}。
    来源：app.v_dwd_assistant_service_log。
    ⚠️ 废单排除：is_delete = 0。
    """
```

### 组件 6：Pydantic Schema（新增）

**位置**：`apps/backend/app/schemas/xcx_customers.py`（新增）+ `apps/backend/app/schemas/xcx_coaches.py`（新增）

#### 客户相关 Schema

```python
# ── xcx_customers.py ──

class AiInsight(CamelModel):
    summary: str = ""
    strategies: list[AiStrategy] = []

class AiStrategy(CamelModel):
    color: str
    text: str

class MetricItem(CamelModel):
    label: str
    value: str
    color: str | None = None

class CoachTask(CamelModel):
    name: str
    level: str          # star / senior / middle / junior
    level_color: str
    task_type: str
    task_color: str
    bg_class: str
    status: str
    last_service: str | None = None
    metrics: list[MetricItem] = []

class FavoriteCoach(CamelModel):
    emoji: str          # 💖 / 💛
    name: str
    relation_index: str
    index_color: str
    bg_class: str
    stats: list[MetricItem] = []

class CoachServiceItem(CamelModel):
    name: str
    level: str
    level_color: str
    course_type: str    # "基础课" / "激励课"
    hours: float
    perf_hours: float | None = None
    fee: float

class ConsumptionRecord(CamelModel):
    id: str
    type: str           # table / shop / recharge
    date: str
    table_name: str | None = None
    start_time: str | None = None
    end_time: str | None = None
    duration: int | None = None
    table_fee: float | None = None
    table_orig_price: float | None = None
    coaches: list[CoachServiceItem] = []
    food_amount: float | None = None
    food_orig_price: float | None = None
    total_amount: float
    total_orig_price: float
    pay_method: str
    recharge_amount: float | None = None

class RetentionClue(CamelModel):
    type: str
    text: str

class CustomerNote(CamelModel):
    id: int
    tag_label: str
    created_at: str
    content: str

class CustomerDetailResponse(CamelModel):
    """CUST-1 响应。"""
    # 基础信息
    id: int
    name: str
    phone: str
    phone_full: str
    avatar: str
    member_level: str
    relation_index: str
    tags: list[str] = []
    # Banner 概览
    balance: float | None = None
    consumption_60d: float | None = None
    ideal_interval: int | None = None
    days_since_visit: int | None = None
    # 扩展模块
    ai_insight: AiInsight = AiInsight()
    coach_tasks: list[CoachTask] = []
    favorite_coaches: list[FavoriteCoach] = []
    retention_clues: list[RetentionClue] = []
    consumption_records: list[ConsumptionRecord] = []
    notes: list[CustomerNote] = []

class ServiceRecordItem(CamelModel):
    id: str
    date: str
    time_range: str | None = None
    table: str | None = None
    type: str
    type_class: str
    record_type: str | None = None  # course / recharge
    duration: float
    duration_raw: float | None = None
    income: float
    is_estimate: bool = False
    drinks: str | None = None

class CustomerRecordsResponse(CamelModel):
    """CUST-2 响应。"""
    customer_name: str
    customer_phone: str
    customer_phone_full: str
    relation_index: str
    tables: list[dict] = []
    total_service_count: int
    month_count: int
    month_hours: float
    records: list[ServiceRecordItem] = []
    has_more: bool = False
```

#### 助教相关 Schema

```python
# ── xcx_coaches.py ──

class PerformanceMetrics(CamelModel):
    monthly_hours: float
    monthly_salary: float
    customer_balance: float
    tasks_completed: int
    perf_current: float
    perf_target: float

class IncomeItem(CamelModel):
    label: str
    amount: str
    color: str

class IncomeSection(CamelModel):
    this_month: list[IncomeItem] = []
    last_month: list[IncomeItem] = []

class CoachTaskItem(CamelModel):
    type_label: str
    type_class: str
    customer_name: str
    customer_id: int | None = None
    note_count: int = 0
    pinned: bool = False
    notes: list[dict] | None = None

class AbandonedTask(CamelModel):
    customer_name: str
    reason: str

class TopCustomer(CamelModel):
    id: int
    name: str
    initial: str
    avatar_gradient: str
    heart_emoji: str    # ❤️ / 💛 / 🤍
    score: str
    score_color: str
    service_count: int
    balance: str
    consume: str

class CoachServiceRecord(CamelModel):
    customer_id: int | None = None
    customer_name: str
    initial: str
    avatar_gradient: str
    type: str
    type_class: str
    table: str | None = None
    duration: str
    income: str
    date: str
    perf_hours: str | None = None

class HistoryMonth(CamelModel):
    month: str
    estimated: bool
    customers: str
    hours: str
    salary: str
    callback_done: int
    recall_done: int

class CoachNoteItem(CamelModel):
    id: int
    content: str
    timestamp: str
    score: int | None = None
    customer_name: str
    tag_label: str
    created_at: str

class CoachDetailResponse(CamelModel):
    """COACH-1 响应。"""
    # 基础信息
    id: int
    name: str
    avatar: str
    level: str
    skills: list[str] = []
    work_years: float = 0
    customer_count: int = 0
    hire_date: str | None = None
    # 绩效
    performance: PerformanceMetrics
    # 收入
    income: IncomeSection
    # 档位
    tier_nodes: list[float] = []
    # 任务分组
    visible_tasks: list[CoachTaskItem] = []
    hidden_tasks: list[CoachTaskItem] = []
    abandoned_tasks: list[AbandonedTask] = []
    # TOP 客户
    top_customers: list[TopCustomer] = []
    # 近期服务记录
    service_records: list[CoachServiceRecord] = []
    # 历史月份
    history_months: list[HistoryMonth] = []
    # 备注
    notes: list[CoachNoteItem] = []
```

## 数据模型

### FDW 查询模式

所有 FDW 查询遵循 `fdw_queries.py` 已有的统一模式：

```python
# 通过 _fdw_context() 上下文管理器：
# 1. 获取 ETL 直连（get_etl_readonly_connection(site_id)）
# 2. BEGIN + SET LOCAL app.current_site_id = site_id
# 3. 执行查询
# 4. COMMIT + 关闭连接
with _fdw_context(conn, site_id) as cur:
    cur.execute("SELECT ... FROM app.v_* WHERE ...", params)
```

### 核心 SQL 查询设计

#### Q1: 客户近 60 天消费（CUST-1 Banner）

```sql
-- ⚠️ DWD-DOC 规则 1: 使用 ledger_amount（items_sum 口径）
-- ⚠️ 废单排除: is_delete = 0
SELECT COALESCE(SUM(ledger_amount), 0) AS consumption_60d
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s
  AND is_delete = 0
  AND create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
```

#### Q2: 关系指数查询（CUST-1 favoriteCoaches）

```sql
-- 按关系指数降序排列
SELECT site_assistant_id AS assistant_id,
       relation_index,
       service_count,
       total_hours,
       total_income
FROM app.v_dws_member_assistant_relation_index
WHERE tenant_member_id = %s
ORDER BY relation_index DESC
```

#### Q3: 消费记录嵌套查询（CUST-1 consumptionRecords）

```sql
-- 主查询：结算单级别
-- ⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount
-- ⚠️ 废单排除: is_delete = 0
SELECT sl.assistant_service_id AS id,
       sl.create_time AS settle_time,
       sl.start_use_time AS start_time,
       sl.last_use_time AS end_time,
       sl.income_seconds / 3600.0 AS service_hours,
       sl.ledger_amount AS total_amount,
       sl.skill_name AS course_type,
       sl.site_table_id AS table_id,
       sl.site_assistant_id AS assistant_id,
       da.assistant_name,
       da.assistant_level_name AS level
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_assistant da
    ON sl.site_assistant_id = da.assistant_id
WHERE sl.tenant_member_id = %s
  AND sl.is_delete = 0
ORDER BY sl.create_time DESC
LIMIT %s OFFSET %s
```

#### Q4: 客户按月服务记录（CUST-2）

```sql
-- ⚠️ DQ-6: 助教姓名通过 v_dim_assistant 获取
-- ⚠️ DWD-DOC 规则 1: income 使用 ledger_amount
-- ⚠️ 废单排除: is_delete = 0
SELECT sl.assistant_service_id AS id,
       sl.create_time,
       sl.start_use_time,
       sl.last_use_time,
       sl.income_seconds / 3600.0 AS service_hours,
       sl.real_use_seconds / 3600.0 AS service_hours_raw,
       sl.ledger_amount AS income,
       sl.skill_name AS course_type,
       sl.site_table_id AS table_id,
       da.assistant_name
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_assistant da
    ON sl.site_assistant_id = da.assistant_id
WHERE sl.tenant_member_id = %s
  AND sl.is_delete = 0
  AND sl.create_time >= %s::timestamptz
  AND sl.create_time < %s::timestamptz
ORDER BY sl.create_time DESC
LIMIT %s OFFSET %s

-- 月度统计汇总
SELECT COUNT(*) AS month_count,
       COALESCE(SUM(income_seconds / 3600.0), 0) AS month_hours
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s
  AND is_delete = 0
  AND create_time >= %s::timestamptz
  AND create_time < %s::timestamptz

-- 累计服务总次数
SELECT COUNT(*) AS total_service_count
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s AND is_delete = 0
```

#### Q5: 助教基本信息（COACH-1）

```sql
SELECT assistant_id,
       assistant_name AS name,
       assistant_level_name AS level,
       -- skills/work_years/hire_date 视图中可能不存在，使用默认值
       created_at AS hire_date
FROM app.v_dim_assistant
WHERE assistant_id = %s
```

#### Q6: 助教 TOP 客户（COACH-1）

```sql
-- ⚠️ DQ-6: 客户姓名通过 tenant_member_id JOIN v_dim_member
-- ⚠️ DQ-7: 余额通过 tenant_member_id JOIN v_dim_member_card_account
-- ⚠️ DWD-DOC 规则 1: consume 使用 ledger_amount
-- ⚠️ 废单排除: is_delete = 0
SELECT sl.tenant_member_id AS member_id,
       dm.nickname AS customer_name,
       COUNT(*) AS service_count,
       COALESCE(SUM(sl.ledger_amount), 0) AS total_consume,
       mca.balance AS customer_balance
FROM app.v_dwd_assistant_service_log sl
LEFT JOIN app.v_dim_member dm
    ON sl.tenant_member_id = dm.member_id AND dm.scd2_is_current = 1
LEFT JOIN app.v_dim_member_card_account mca
    ON sl.tenant_member_id = mca.tenant_member_id AND mca.scd2_is_current = 1
WHERE sl.site_assistant_id = %s
  AND sl.is_delete = 0
GROUP BY sl.tenant_member_id, dm.nickname, mca.balance
ORDER BY service_count DESC
LIMIT %s
```

#### Q7: 历史月份统计（COACH-1 historyMonths）

```sql
-- 多月绩效数据批量查询
SELECT salary_month,
       effective_hours,
       gross_salary,
       base_income,
       bonus_income
FROM app.v_dws_assistant_salary_calc
WHERE assistant_id = %s
  AND salary_month = ANY(%s::date[])
ORDER BY salary_month DESC

-- 各月不重复客户数
SELECT DATE_TRUNC('month', create_time)::date AS month,
       COUNT(DISTINCT tenant_member_id) AS customer_count
FROM app.v_dwd_assistant_service_log
WHERE site_assistant_id = %s
  AND is_delete = 0
  AND create_time >= %s::timestamptz
  AND create_time < %s::timestamptz
GROUP BY DATE_TRUNC('month', create_time)::date
```

#### Q8: 助教近 60 天统计（CUST-1 coachTasks metrics）

```sql
-- 特定助教对特定客户的近 60 天统计
-- ⚠️ 废单排除: is_delete = 0
SELECT COUNT(*) AS service_count,
       COALESCE(SUM(income_seconds / 3600.0), 0) AS total_hours,
       CASE WHEN COUNT(*) > 0
            THEN SUM(income_seconds / 3600.0) / COUNT(*)
            ELSE 0 END AS avg_hours
FROM app.v_dwd_assistant_service_log
WHERE site_assistant_id = %s
  AND tenant_member_id = %s
  AND is_delete = 0
  AND create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
```

### 列名映射汇总

| 代码语义 | 实际视图列名 | 视图 | 说明 |
|----------|-------------|------|------|
| member_id | `tenant_member_id` | v_dwd_assistant_service_log | 会员 ID |
| assistant_id (WHERE) | `site_assistant_id` | v_dwd_assistant_service_log | 助教 ID |
| settle_time | `create_time` | v_dwd_assistant_service_log | 结算时间 |
| income | `ledger_amount` | v_dwd_assistant_service_log | items_sum 口径 |
| service_hours | `income_seconds / 3600.0` | v_dwd_assistant_service_log | 折算工时 |
| is_trash=false | `is_delete = 0` | v_dwd_assistant_service_log | 废单排除 |
| course_type | `skill_name` | v_dwd_assistant_service_log | 课程类型 |
| calc_month | `salary_month` | v_dws_assistant_salary_calc | 月份（date） |
| coach_level | `assistant_level_name` | v_dws_assistant_salary_calc | 档位名称 |
| total_hours | `effective_hours` | v_dws_assistant_salary_calc | 有效总工时 |
| total_income | `gross_salary` | v_dws_assistant_salary_calc | 总收入 |
| pd_money_total | `base_income` | v_dws_assistant_salary_calc | 基础课总收入 |
| cx_money_total | `bonus_income` | v_dws_assistant_salary_calc | 激励课总收入 |

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

| 规则 | 实施位置 | 具体措施 |
|------|---------|---------|
| 规则 1: `items_sum` 口径 | `fdw_queries.py` 所有金额查询 | SQL 中使用 `ledger_amount`，禁止 `consume_money` |
| 规则 2: 助教费用拆分 | `fdw_queries.py` + `customer_service.py` | 使用 `base_income`/`bonus_income`，禁止 `service_fee` |
| DQ-6: 会员信息 JOIN | `fdw_queries.py` 所有含客户姓名的查询 | `tenant_member_id JOIN v_dim_member WHERE scd2_is_current=1` |
| DQ-7: 会员卡 JOIN | `fdw_queries.py` 余额查询 | `tenant_member_id JOIN v_dim_member_card_account WHERE scd2_is_current=1` |
| 废单排除 | `fdw_queries.py` 所有服务记录查询 | `WHERE is_delete = 0`，禁止引用 `dwd_assistant_trash_event` |

## 正确性属性

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

### Property 1: 消费记录金额拆分不变量

*For any* 消费记录，`totalAmount` 应等于 `tableFee + foodAmount + SUM(coaches[].fee)`（在浮点精度 0.01 范围内）。这验证了 `items_sum` 口径拆分的一致性——各子项之和必须等于总额。

**Validates: Requirements 14.1, 4.4**

### Property 2: 废单排除一致性

*For any* 服务记录查询（CUST-1 consumptionRecords、CUST-2 records、COACH-1 serviceRecords/topCustomers），返回的记录集合中不应包含 `is_delete != 0` 的记录。即对所有返回记录，`is_delete` 字段值必须为 0。

**Validates: Requirements 14.8, 4.6, 7.8, 10.7**

### Property 3: 助教费用拆分正确性

*For any* 消费记录中的 `coaches` 子数组，`courseType` 为 `"基础课"` 的记录其 `fee` 应对应 `assistant_pd_money`，`courseType` 为 `"激励课"` 的记录其 `fee` 应对应 `assistant_cx_money`。两者之和应等于该结算单的助教费用总额。

**Validates: Requirements 14.2, 4.3, 9.2**

### Property 4: favoriteCoaches 排序不变量

*For any* CUST-1 响应中的 `favoriteCoaches` 列表，列表应按 `relationIndex` 降序排列——即对于相邻两项 `favoriteCoaches[i]` 和 `favoriteCoaches[i+1]`，`float(favoriteCoaches[i].relationIndex) >= float(favoriteCoaches[i+1].relationIndex)` 恒成立。

**Validates: Requirements 14.5, 6.1**

### Property 5: historyMonths 排序与预估标记

*For any* COACH-1 响应中的 `historyMonths` 列表，列表应按月份降序排列（最近月份在前），且第一条的 `estimated` 必须为 `true`（本月为预估数据），其余条目的 `estimated` 必须为 `false`。

**Validates: Requirements 14.6, 12.5**

### Property 6: 列表上限约束

*For any* CUST-1 响应中的 `notes` 列表长度应 ≤ 20，COACH-1 响应中的 `topCustomers` 列表长度应 ≤ 20，COACH-1 响应中的 `notes` 列表长度应 ≤ 20。即所有带上限的列表返回数量不超过其指定上限。

**Validates: Requirements 3.2, 10.1, 11.5**

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

*For any* 服务记录集合，CUST-2 的 `monthCount` 应等于当月返回的记录总数，`monthHours` 应等于当月所有记录 `service_hours` 之和（在浮点精度 0.01 范围内）。同理，CUST-1 coachTasks 的 `metrics` 中近 60 天统计的 `service_count` 应等于实际记录数，`total_hours` 应等于各记录工时之和。

**Validates: Requirements 7.6, 5.3**

### Property 8: daysSinceVisit 计算正确性

*For any* 有效日期 `lastVisitDate`（不晚于今天），`daysSinceVisit` 应等于 `(current_date - lastVisitDate).days`，且结果为非负整数。无服务记录时应返回 `null`。

**Validates: Requirements 1.6**

### Property 9: emoji 映射正确性

*For any* 关系指数值 `relationIndex`（0.0 到 1.0 之间的浮点数）：
- CUST-1 favoriteCoaches：`relationIndex >= 0.7` 时 `emoji` 应为 `"💖"`，`relationIndex < 0.7` 时应为 `"💛"`
- COACH-1 topCustomers：`score >= 0.7` 时 `heartEmoji` 应为 `"❤️"`，`0.3 <= score < 0.7` 时应为 `"💛"`，`score < 0.3` 时应为 `"🤍"`

映射函数应对相同输入始终产生相同输出（确定性）。

**Validates: Requirements 6.4**

### Property 10: 优雅降级

*For any* 扩展模块（`aiInsight`/`coachTasks`/`favoriteCoaches`/`consumptionRecords`/`historyMonths`）的数据源查询失败时，该模块应返回空默认值（空数组或空对象），不影响其他模块和整体响应的 HTTP 200 状态码。

**Validates: Requirements 1.7, 13.8**

### Property 11: 任务分组正确性

*For any* 助教的任务集合，按 `status` 分组后：`active` 状态任务应全部出现在 `visibleTasks` 中，`inactive` 状态任务应全部出现在 `hiddenTasks` 中，`abandoned` 状态任务应全部出现在 `abandonedTasks` 中。三组之间无交集，且三组之并等于原始任务集合（不含 `completed` 状态）。

**Validates: Requirements 11.1**

### Property 12: 数据隔离不变量

*For any* CUST-1 响应中的 `coachTasks`，每条任务的 `member_id` 应等于请求路径中的 `customerId`。*For any* COACH-1 响应中的 `serviceRecords`，每条记录的 `assistant_id` 应等于请求路径中的 `coachId`。

**Validates: Requirements 14.3, 14.4**

### Property 13: 分页与 hasMore 正确性

*For any* CUST-2 记录集合和分页参数 `(page, pageSize)`，返回的记录数应 ≤ `pageSize`，`hasMore` 为 `true` 当且仅当总记录数 > `page * pageSize`。记录应按 `create_time` 倒序排列。

**Validates: Requirements 7.9**

### Property 14: 幂等性

*For any* 相同参数的 CUST-2 请求（相同 `customerId`、`year`、`month`），在数据未变更的情况下，两次请求应返回相同的 `monthCount` 和 `monthHours` 值。即 `f(x) == f(x)` 对所有合法输入成立。

**Validates: Requirements 14.7**

## 错误处理

### 后端错误处理

| 错误场景 | HTTP 状态码 | 响应 | 触发位置 |
|---------|:----------:|------|---------|
| 用户未通过审核 | 403 | `{ code: 403, message: "用户未通过审核，无法访问此资源" }` | `require_approved()` |
| 客户不存在 | 404 | `{ code: 404, message: "客户不存在" }` | `customer_service.get_customer_detail()` |
| 助教不存在 | 404 | `{ code: 404, message: "助教不存在" }` | `coach_service.get_coach_detail()` |
| year/month 参数无效 | 422 | Pydantic 验证错误 | FastAPI 参数校验 |
| page/pageSize 参数无效 | 422 | Pydantic 验证错误 | FastAPI 参数校验 |
| FDW 查询超时/失败（核心字段） | 500 | `{ code: 500, message: "Internal Server Error" }` | `unhandled_exception_handler` |
| FDW 查询失败（扩展模块） | 200 | 该模块返回空默认值 | 各 `_build_*` 函数 |
| 数据库连接失败 | 500 | `{ code: 500, message: "Internal Server Error" }` | `unhandled_exception_handler` |

### FDW 查询容错策略

**核心字段**（基础信息、Banner 概览）查询失败直接抛出 500。

**扩展模块**（aiInsight/coachTasks/favoriteCoaches/consumptionRecords/historyMonths）采用优雅降级：

```python
# 每个扩展模块独立 try/except
try:
    ai_insight = _build_ai_insight(customer_id, site_id, conn)
except Exception:
    logger.warning("构建 aiInsight 失败，降级为空", exc_info=True)
    ai_insight = {"summary": "", "strategies": []}

try:
    coach_tasks = _build_coach_tasks(customer_id, site_id, conn)
except Exception:
    logger.warning("构建 coachTasks 失败，降级为空列表", exc_info=True)
    coach_tasks = []

# ... 其他扩展模块同理
```

### 前端错误处理

| 场景 | 处理方式 |
|------|---------|
| API 返回 403 | 显示"权限不足"提示 |
| API 返回 404 | 显示"数据不存在"提示，返回上一页 |
| API 返回 500 | 显示"服务器错误"toast |
| 扩展模块字段为空数组/空对象 | 前端隐藏对应 UI 区域 |
| 月份切换期间网络失败 | 恢复上一月份状态，显示重试提示 |
| Banner 字段为 null | 显示 `"--"` 占位符 |

## 测试策略

### 双轨测试方法

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

- **属性测试**：验证金额拆分、排序不变量、聚合正确性、映射函数、分页逻辑等通用规则在所有输入上的正确性
- **单元测试**：验证具体的 API 端点行为、权限校验、FDW 查询集成、边界条件、优雅降级

### 属性测试配置

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

### 属性测试清单

| Property | 测试函数 | 生成器 | 验证逻辑 |
|----------|---------|--------|---------|
| P1: 金额拆分不变量 | `test_consumption_amount_split` | `st.floats(min_value=0, max_value=1e5)` 生成 tableFee/foodAmount/coachFees | `abs(totalAmount - (tableFee + foodAmount + sum(fees))) < 0.01` |
| P2: 废单排除一致性 | `test_deleted_records_excluded` | `st.lists(st.fixed_dictionaries({is_delete: st.integers(0,2), ...}))` | 过滤后结果中所有 `is_delete == 0` |
| P3: 助教费用拆分 | `test_coach_fee_split` | `st.floats` 生成 pd_money/cx_money + `st.sampled_from(["基础课","激励课"])` | 基础课→pd_money，激励课→cx_money，两者之和=总额 |
| P4: favoriteCoaches 排序 | `test_favorite_coaches_ordering` | `st.lists(st.floats(0, 1))` 生成 relationIndex 列表 | 排序后每项 ≥ 下一项 |
| P5: historyMonths 排序与标记 | `test_history_months_ordering` | `st.lists(st.dates(), min_size=1)` 生成月份列表 | 降序排列，首项 estimated=True，其余 False |
| P6: 列表上限 | `test_list_size_limits` | `st.integers(0, 100)` 生成记录数 | notes ≤ 20，topCustomers ≤ 20 |
| P7: 月度汇总聚合 | `test_monthly_summary_aggregation` | `st.lists(st.fixed_dictionaries({hours: st.floats(0,10), income: st.floats(0,1e4)}))` | count=len，hours=sum(hours)，monthHours=sum(hours) |
| P8: daysSinceVisit 计算 | `test_days_since_visit` | `st.dates(max_value=date.today())` | days = (today - date).days，非负 |
| P9: emoji 映射 | `test_emoji_mapping` | `st.floats(0, 1)` 生成 relationIndex | ≥0.7→💖，<0.7→💛；heartEmoji 三级映射正确 |
| P10: 优雅降级 | `test_graceful_degradation` | `st.sampled_from(MODULES)` 选择失败模块 | 失败模块返回空默认值，其他模块正常 |
| P11: 任务分组 | `test_task_grouping` | `st.lists(st.fixed_dictionaries({status: st.sampled_from(STATUSES)}))` | active→visible，inactive→hidden，abandoned→abandoned，无交集 |
| P12: 数据隔离 | `test_data_isolation` | `st.integers(1, 1000)` 生成 customerId/coachId | coachTasks 每条 member_id=customerId，serviceRecords 每条 assistant_id=coachId |
| P13: 分页与 hasMore | `test_pagination_has_more` | `st.integers(1,100)` total + `st.integers(1,10)` page/pageSize | 记录数 ≤ pageSize，hasMore = (total > page*pageSize) |
| P14: 幂等性 | `test_idempotent_query` | `st.integers(1,12)` month + `st.integers(2020,2026)` year | f(x) == f(x) 对 monthCount/monthHours |

### 单元测试清单

| 测试目标 | 测试文件 | 关键用例 |
|---------|---------|---------|
| CUST-1 端点 | `tests/unit/test_customer_detail.py` | 完整响应结构；Banner 字段正确；aiInsight 无缓存降级；consumptionRecords 嵌套结构 |
| CUST-1 coachTasks | `tests/unit/test_customer_coach_tasks.py` | 关联助教列表；metrics 近 60 天统计；levelColor 映射 |
| CUST-1 favoriteCoaches | `tests/unit/test_customer_favorites.py` | 关系指数排序；emoji 映射；stats 4 项指标 |
| CUST-2 端点 | `tests/unit/test_customer_records.py` | 按月查询；monthCount/monthHours 汇总；totalServiceCount 跨月；hasMore 分页 |
| COACH-1 端点 | `tests/unit/test_coach_detail.py` | 完整响应结构；performance 6 指标；income thisMonth/lastMonth 各 4 项 |
| COACH-1 topCustomers | `tests/unit/test_coach_top_customers.py` | 最多 20 条；heartEmoji 三级映射；consume items_sum 口径 |
| COACH-1 historyMonths | `tests/unit/test_coach_history.py` | 最近 5+ 个月；本月 estimated=true；回访/召回完成数 |
| COACH-1 任务分组 | `tests/unit/test_coach_task_groups.py` | 三组分组正确；abandonedTasks 含 reason；notes 关联 |
| 权限校验 | `tests/unit/test_auth_rns12.py` | 未审核用户 403；客户不存在 404；助教不存在 404 |
| FDW 查询扩展 | `tests/unit/test_fdw_queries_rns12.py` | DQ-6 JOIN 正确；DQ-7 余额查询；is_delete=0 排除；items_sum 口径 |
| 优雅降级 | `tests/unit/test_degradation_rns12.py` | aiInsight 失败→空；coachTasks 失败→空列表；favoriteCoaches 失败→空列表 |

### 测试执行命令

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

# 单元测试
cd apps/backend && pytest tests/unit/ -v -k "customer_detail or customer_records or coach_detail or coach_top or coach_history or coach_task_groups or auth_rns12 or fdw_queries_rns12 or degradation_rns12"
```
