1069 lines
40 KiB
Markdown
1069 lines
40 KiB
Markdown
# 技术设计文档 — 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:\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"
|
||
```
|