Files
Neo-ZQYY/.kiro/specs/rns1-customer-coach-api/design.md

1069 lines
40 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术设计文档 — RNS1.2:客户与助教接口
## 概述
RNS1.2 实现客户详情CUST-1、客户服务记录CUST-2、助教详情COACH-13 个接口,覆盖 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 }
```
## 组件与接口
### 组件 1xcx_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)
```
### 组件 2xcx_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)
```
### 组件 3customer_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 → aiInsightcache_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 → notestarget_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
"""
```
### 组件 4coach_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 口径
"""
```
### 组件 5fdw_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_amountitems_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 规则 1totalAmount 使用 ledger_amount。
⚠️ DWD-DOC 规则 2coaches 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_logCOUNT 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_logCOUNT(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 规则 1consume 使用 ledger_amountitems_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。
"""
```
### 组件 6Pydantic 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_amountitems_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` 列表长度应 ≤ 20COACH-1 响应中的 `topCustomers` 列表长度应 ≤ 20COACH-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 ≤ 20topCustomers ≤ 20 |
| P7: 月度汇总聚合 | `test_monthly_summary_aggregation` | `st.lists(st.fixed_dictionaries({hours: st.floats(0,10), income: st.floats(0,1e4)}))` | count=lenhours=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→visibleinactive→hiddenabandoned→abandoned无交集 |
| P12: 数据隔离 | `test_data_isolation` | `st.integers(1, 1000)` 生成 customerId/coachId | coachTasks 每条 member_id=customerIdserviceRecords 每条 assistant_id=coachId |
| P13: 分页与 hasMore | `test_pagination_has_more` | `st.integers(1,100)` total + `st.integers(1,10)` page/pageSize | 记录数 ≤ pageSizehasMore = (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 含 reasonnotes 关联 |
| 权限校验 | `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:\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"
```