feat: batch update - gift card breakdown spec, backend APIs, miniprogram pages, ETL finance recharge, docs & migrations
This commit is contained in:
1
.kiro/specs/gift-card-breakdown/.config.kiro
Normal file
1
.kiro/specs/gift-card-breakdown/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "4b6736e7-40fc-40a9-82f7-809f80253fe2", "workflowType": "requirements-first", "specType": "feature"}
|
||||
481
.kiro/specs/gift-card-breakdown/design.md
Normal file
481
.kiro/specs/gift-card-breakdown/design.md
Normal file
@@ -0,0 +1,481 @@
|
||||
# Design Document: 赠送卡矩阵细分数据 (gift-card-breakdown)
|
||||
|
||||
## Overview
|
||||
|
||||
本设计解决 BOARD-3 财务看板赠送卡 3×4 矩阵中细分单元格全部返回 0 的问题。根因是 DWS 层 `dws_finance_recharge_summary` 仅存储赠送卡总额,未按卡类型(酒水卡/台费卡/抵用券)拆分。
|
||||
|
||||
改动贯穿全栈数据链路:DDL 新增 6 个字段 → ETL 拆分填充 → RLS 视图 + FDW 同步 → 后端 SQL 查询 + 接口返回 → 小程序替换 mock 数据。
|
||||
|
||||
关键约束:消费行无法按卡类型拆分(上游飞球 API 的 `dwd_settlement_head.gift_card_amount` 仅提供总额),消费行细分列保持 0。
|
||||
|
||||
## Architecture
|
||||
|
||||
### 数据流向
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph DWD["DWD 层(数据源)"]
|
||||
DCA["dim_member_card_account<br/>card_type_id + balance"]
|
||||
DRO["dwd_recharge_order<br/>point_amount"]
|
||||
DSH["dwd_settlement_head<br/>gift_card_amount(仅总额)"]
|
||||
end
|
||||
|
||||
subgraph ETL["ETL(FinanceRechargeTask)"]
|
||||
ECB["_extract_card_balances()<br/>按 card_type_id 分组余额"]
|
||||
EGR["_extract_gift_recharge_breakdown()<br/>JOIN 按 card_type_id 分组新增"]
|
||||
TF["transform()<br/>合并 6 个新字段到 record"]
|
||||
end
|
||||
|
||||
subgraph DWS["DWS 层"]
|
||||
DFS["dws_finance_recharge_summary<br/>+6 新字段"]
|
||||
end
|
||||
|
||||
subgraph VIEW["视图 + FDW"]
|
||||
RLS["app.v_dws_finance_recharge_summary<br/>RLS 行级安全"]
|
||||
FDW["FDW 外部表<br/>业务库映射"]
|
||||
end
|
||||
|
||||
subgraph BACKEND["后端"]
|
||||
FQ["fdw_queries.get_finance_recharge()<br/>SQL SUM 6 字段"]
|
||||
BS["board_service._build_recharge()<br/>环比计算(自动适配)"]
|
||||
end
|
||||
|
||||
subgraph MP["小程序"]
|
||||
BF["board-finance.ts<br/>替换 mock → API 调用"]
|
||||
end
|
||||
|
||||
DCA --> ECB
|
||||
DRO -->|JOIN| EGR
|
||||
DCA -->|card_type_id| EGR
|
||||
DSH -.->|总额,无法拆分| TF
|
||||
ECB --> TF
|
||||
EGR --> TF
|
||||
TF --> DFS
|
||||
DFS --> RLS
|
||||
RLS --> FDW
|
||||
FDW --> FQ
|
||||
FQ --> BS
|
||||
BS --> BF
|
||||
```
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 层级 | 文件 | 改动类型 |
|
||||
|------|------|----------|
|
||||
| DDL | `db/etl_feiqiu/migrations/2026-xx-xx_add_gift_breakdown_fields.sql` | 新增 |
|
||||
| DDL 基线 | `docs/database/ddl/etl_feiqiu__dws.sql` | 修改 |
|
||||
| ETL | `apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py` | 修改 |
|
||||
| RLS 视图 | `db/zqyy_app/migrations/` | 新增 |
|
||||
| FDW | `db/fdw/` | 新增/修改 |
|
||||
| 后端 | `apps/backend/app/services/fdw_queries.py` | 修改 |
|
||||
| 小程序 | `apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts` | 修改 |
|
||||
| BD 手册 | `apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_finance_recharge_summary.md` | 修改 |
|
||||
|
||||
不需要修改的文件:
|
||||
- `apps/backend/app/schemas/xcx_board.py` — `GiftCell`/`GiftRow`/`RechargePanel` 已预留 `liquor`/`table_fee`/`voucher` 字段
|
||||
- `apps/backend/app/services/board_service.py` — `_build_recharge()` 环比逻辑已遍历 `gift_rows` 所有 cell,自动适配
|
||||
- `apps/backend/app/routers/xcx_board.py` — 路由无需变更
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### 1. DDL 迁移脚本
|
||||
|
||||
新增 6 个 `NUMERIC(14,2)` 字段到 `dws.dws_finance_recharge_summary`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE dws.dws_finance_recharge_summary
|
||||
ADD COLUMN IF NOT EXISTS gift_liquor_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS gift_table_fee_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS gift_voucher_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS gift_liquor_recharge NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS gift_table_fee_recharge NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS gift_voucher_recharge NUMERIC(14,2) NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
### 2. ETL — `FinanceRechargeTask` 修改
|
||||
|
||||
#### 2a. `_extract_card_balances()` 拆分
|
||||
|
||||
当前实现将三种赠送卡余额合并为 `gift_balance`。修改后按 `card_type_id` 分别返回:
|
||||
|
||||
```python
|
||||
# 现有常量(沿用)
|
||||
CASH_CARD_TYPE_ID = 2793249295533893
|
||||
GIFT_CARD_TYPE_IDS = [2791990152417157, 2793266846533445, 2794699703437125]
|
||||
|
||||
# card_type_id → 字段名映射
|
||||
GIFT_TYPE_FIELD_MAP = {
|
||||
2794699703437125: 'gift_liquor_balance', # 酒水卡
|
||||
2791990152417157: 'gift_table_fee_balance', # 台费卡
|
||||
2793266846533445: 'gift_voucher_balance', # 抵用券
|
||||
}
|
||||
```
|
||||
|
||||
返回值新增 3 个 key:`gift_liquor_balance`、`gift_table_fee_balance`、`gift_voucher_balance`。原有 `gift_balance` 保持不变(向后兼容)。
|
||||
|
||||
#### 2b. 新增 `_extract_gift_recharge_breakdown()`
|
||||
|
||||
通过 `dwd_recharge_order JOIN dim_member_card_account`(关联 `tenant_member_card_id` → `tenant_member_id`)按 `card_type_id` 分组查询赠送金额(`point_amount`):
|
||||
|
||||
```python
|
||||
GIFT_RECHARGE_FIELD_MAP = {
|
||||
2794699703437125: 'gift_liquor_recharge', # 酒水卡
|
||||
2791990152417157: 'gift_table_fee_recharge', # 台费卡
|
||||
2793266846533445: 'gift_voucher_recharge', # 抵用券
|
||||
}
|
||||
```
|
||||
|
||||
SQL 逻辑:
|
||||
```sql
|
||||
SELECT dca.card_type_id, SUM(ro.point_amount) AS gift_recharge
|
||||
FROM dwd.dwd_recharge_order ro
|
||||
JOIN dwd.dim_member_card_account dca
|
||||
ON ro.tenant_member_card_id = dca.tenant_member_id
|
||||
WHERE ro.site_id = %s
|
||||
AND {biz_date_expr} >= %s AND {biz_date_expr} <= %s
|
||||
AND dca.card_type_id IN (2794699703437125, 2791990152417157, 2793266846533445)
|
||||
AND dca.scd2_is_current = 1
|
||||
AND COALESCE(dca.is_delete, 0) = 0
|
||||
GROUP BY dca.card_type_id
|
||||
```
|
||||
|
||||
返回 `dict`:`{gift_liquor_recharge: Decimal, gift_table_fee_recharge: Decimal, gift_voucher_recharge: Decimal}`,缺失的卡类型默认 0。
|
||||
|
||||
#### 2c. `extract()` 调用新方法
|
||||
|
||||
```python
|
||||
def extract(self, context):
|
||||
# ... 现有逻辑 ...
|
||||
recharge_summary = self._extract_recharge_summary(site_id, start_date, end_date)
|
||||
card_balances = self._extract_card_balances(site_id, end_date)
|
||||
gift_recharge_breakdown = self._extract_gift_recharge_breakdown(site_id, start_date, end_date)
|
||||
return {
|
||||
'recharge_summary': recharge_summary,
|
||||
'card_balances': card_balances,
|
||||
'gift_recharge_breakdown': gift_recharge_breakdown,
|
||||
'start_date': start_date, 'end_date': end_date, 'site_id': site_id,
|
||||
}
|
||||
```
|
||||
|
||||
#### 2d. `transform()` 合并新字段
|
||||
|
||||
在 `record` dict 构建时追加 6 个字段:
|
||||
|
||||
```python
|
||||
record = {
|
||||
# ... 现有字段 ...
|
||||
'gift_liquor_balance': self.safe_decimal(balance.get('gift_liquor_balance', 0)),
|
||||
'gift_table_fee_balance': self.safe_decimal(balance.get('gift_table_fee_balance', 0)),
|
||||
'gift_voucher_balance': self.safe_decimal(balance.get('gift_voucher_balance', 0)),
|
||||
'gift_liquor_recharge': self.safe_decimal(gift_recharge.get('gift_liquor_recharge', 0)),
|
||||
'gift_table_fee_recharge': self.safe_decimal(gift_recharge.get('gift_table_fee_recharge', 0)),
|
||||
'gift_voucher_recharge': self.safe_decimal(gift_recharge.get('gift_voucher_recharge', 0)),
|
||||
}
|
||||
```
|
||||
|
||||
幂等策略:沿用现有 delete-before-insert(按 `site_id` + `stat_date`),新字段随整行写入。
|
||||
|
||||
### 3. RLS 视图重建
|
||||
|
||||
`app.v_dws_finance_recharge_summary` 需要 `CREATE OR REPLACE VIEW` 更新列列表,包含 6 个新字段。RLS 策略基于 `site_id`,新字段自动继承。
|
||||
|
||||
### 4. FDW 外部表同步
|
||||
|
||||
通过 `IMPORT FOREIGN SCHEMA` 重新导入,幂等脚本已支持(先 DROP 再 IMPORT)。
|
||||
|
||||
### 5. 后端 — `fdw_queries.get_finance_recharge()`
|
||||
|
||||
SQL 新增 6 个字段的 SUM 聚合:
|
||||
|
||||
```sql
|
||||
SELECT SUM(recharge_cash) AS actual_income,
|
||||
SUM(first_recharge_cash) AS first_charge,
|
||||
SUM(renewal_cash) AS renew_charge,
|
||||
SUM(cash_card_balance) AS card_balance,
|
||||
SUM(total_card_balance) AS all_card_balance,
|
||||
SUM(gift_card_balance) AS gift_balance_total,
|
||||
-- 新增 6 字段
|
||||
SUM(gift_liquor_balance) AS gift_liquor_balance,
|
||||
SUM(gift_table_fee_balance) AS gift_table_fee_balance,
|
||||
SUM(gift_voucher_balance) AS gift_voucher_balance,
|
||||
SUM(gift_liquor_recharge) AS gift_liquor_recharge,
|
||||
SUM(gift_table_fee_recharge) AS gift_table_fee_recharge,
|
||||
SUM(gift_voucher_recharge) AS gift_voucher_recharge
|
||||
FROM app.v_dws_finance_recharge_summary
|
||||
WHERE stat_date >= %s::date AND stat_date <= %s::date
|
||||
```
|
||||
|
||||
`gift_rows` 构建逻辑更新:
|
||||
|
||||
```python
|
||||
gift_rows = [
|
||||
{"label": "新增", "total": _gc(gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge),
|
||||
"liquor": _gc(gift_liquor_recharge), "table_fee": _gc(gift_table_fee_recharge), "voucher": _gc(gift_voucher_recharge)},
|
||||
{"label": "消费", "total": _gc(0.0), # 消费总额从现有字段获取
|
||||
"liquor": _gc(0.0), "table_fee": _gc(0.0), "voucher": _gc(0.0)},
|
||||
{"label": "余额", "total": _gc(gift_balance),
|
||||
"liquor": _gc(gift_liquor_balance), "table_fee": _gc(gift_table_fee_balance), "voucher": _gc(gift_voucher_balance)},
|
||||
]
|
||||
```
|
||||
|
||||
注意:新增行的 `total` 使用三个细分字段之和(而非 `recharge_gift`),保证 `total = liquor + table_fee + voucher` 恒等。
|
||||
|
||||
### 6. 后端 — `_empty_recharge_data()`
|
||||
|
||||
空默认值结构不变(已经是全 0),无需修改。
|
||||
|
||||
### 7. 小程序 — `board-finance.ts`
|
||||
|
||||
替换 mock 数据为真实 API 调用。字段映射:
|
||||
|
||||
| 后端字段 | 小程序字段 |
|
||||
|----------|-----------|
|
||||
| `GiftRow.liquor.value` | `item.wine` |
|
||||
| `GiftRow.table_fee.value` | `item.table` |
|
||||
| `GiftRow.voucher.value` | `item.coupon` |
|
||||
| `GiftCell.compare` | `item.*Compare` |
|
||||
|
||||
数据转换在 API 响应处理层完成(后端返回 camelCase,小程序按现有字段名映射)。
|
||||
|
||||
|
||||
## Data Models
|
||||
|
||||
### DWS 表结构变更
|
||||
|
||||
`dws.dws_finance_recharge_summary` 新增字段:
|
||||
|
||||
| 字段名 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `gift_liquor_balance` | NUMERIC(14,2) | 0 | 酒水卡余额(当日末快照) |
|
||||
| `gift_table_fee_balance` | NUMERIC(14,2) | 0 | 台费卡余额(当日末快照) |
|
||||
| `gift_voucher_balance` | NUMERIC(14,2) | 0 | 抵用券余额(当日末快照) |
|
||||
| `gift_liquor_recharge` | NUMERIC(14,2) | 0 | 酒水卡新增充值(赠送部分) |
|
||||
| `gift_table_fee_recharge` | NUMERIC(14,2) | 0 | 台费卡新增充值(赠送部分) |
|
||||
| `gift_voucher_recharge` | NUMERIC(14,2) | 0 | 抵用券新增充值(赠送部分) |
|
||||
|
||||
### 恒等式约束
|
||||
|
||||
1. 余额恒等:`gift_card_balance = gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance`
|
||||
2. 新增恒等:`recharge_gift = gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge`
|
||||
|
||||
### 卡类型 ID 映射(硬编码常量)
|
||||
|
||||
| card_type_id | 类型 | 余额字段 | 新增字段 |
|
||||
|---|---|---|---|
|
||||
| 2794699703437125 | 酒水卡 | `gift_liquor_balance` | `gift_liquor_recharge` |
|
||||
| 2791990152417157 | 台费卡 | `gift_table_fee_balance` | `gift_table_fee_recharge` |
|
||||
| 2793266846533445 | 抵用券 | `gift_voucher_balance` | `gift_voucher_recharge` |
|
||||
|
||||
### 现有 Schema(无需修改)
|
||||
|
||||
`GiftCell`(Pydantic):
|
||||
```python
|
||||
class GiftCell(CamelModel):
|
||||
value: float
|
||||
compare: str | None = None
|
||||
down: bool | None = None
|
||||
flat: bool | None = None
|
||||
```
|
||||
|
||||
`GiftRow`(Pydantic):
|
||||
```python
|
||||
class GiftRow(CamelModel):
|
||||
label: str # "新增" / "消费" / "余额"
|
||||
total: GiftCell
|
||||
liquor: GiftCell
|
||||
table_fee: GiftCell
|
||||
voucher: GiftCell
|
||||
```
|
||||
|
||||
### ETL 内部数据结构
|
||||
|
||||
`_extract_card_balances()` 返回值扩展:
|
||||
```python
|
||||
{
|
||||
'cash_balance': Decimal,
|
||||
'gift_balance': Decimal, # 保留(向后兼容)
|
||||
'total_balance': Decimal,
|
||||
'gift_liquor_balance': Decimal, # 新增
|
||||
'gift_table_fee_balance': Decimal, # 新增
|
||||
'gift_voucher_balance': Decimal, # 新增
|
||||
}
|
||||
```
|
||||
|
||||
`_extract_gift_recharge_breakdown()` 返回值:
|
||||
```python
|
||||
{
|
||||
'gift_liquor_recharge': Decimal,
|
||||
'gift_table_fee_recharge': Decimal,
|
||||
'gift_voucher_recharge': Decimal,
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||
|
||||
### Property 1: 余额恒等式
|
||||
|
||||
*For any* DWS 记录,`gift_card_balance` 的值必须等于 `gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance`。即对于任意随机生成的三种赠送卡余额,写入 DWS 后总额字段与细分字段之和恒等。
|
||||
|
||||
**Validates: Requirements 1.3, 10.3**
|
||||
|
||||
### Property 2: 新增恒等式
|
||||
|
||||
*For any* DWS 记录,`recharge_gift` 的值必须等于 `gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge`。即对于任意随机生成的三种赠送卡新增金额,写入 DWS 后总额字段与细分字段之和恒等。
|
||||
|
||||
**Validates: Requirements 1.4**
|
||||
|
||||
### Property 3: ETL 余额提取 round-trip
|
||||
|
||||
*For any* 一组 `dim_member_card_account` 记录(包含随机的 `card_type_id` 和 `balance`),`_extract_card_balances()` 返回的各类型余额必须等于对应 `card_type_id` 的 `balance` 之和。当某种卡类型无记录时,对应余额为 0。
|
||||
|
||||
**Validates: Requirements 2.1, 2.2, 2.3, 10.1**
|
||||
|
||||
### Property 4: ETL 新增提取 round-trip
|
||||
|
||||
*For any* 一组 `dwd_recharge_order` + `dim_member_card_account` 记录(包含随机的 `card_type_id` 和 `point_amount`),`_extract_gift_recharge_breakdown()` 返回的各类型新增金额必须等于对应 `card_type_id` 的 `point_amount` 之和。当某种卡类型无充值记录时,对应新增为 0。
|
||||
|
||||
**Validates: Requirements 3.1, 3.2, 3.3, 10.2**
|
||||
|
||||
### Property 5: transform 正确合并细分字段
|
||||
|
||||
*For any* 随机生成的 `card_balances` dict(含 3 个余额字段)和 `gift_recharge_breakdown` dict(含 3 个新增字段),`transform()` 输出的 record 必须包含这 6 个字段且值与输入一致。当输入 dict 缺少某个 key 时,对应字段为 0。
|
||||
|
||||
**Validates: Requirements 4.1, 4.2**
|
||||
|
||||
### Property 6: 后端接口返回正确的细分数据
|
||||
|
||||
*For any* DWS 表中的一组记录(包含随机的 6 个细分字段值),`get_finance_recharge()` 返回的 `gift_rows` 中余额行的 `liquor`/`table_fee`/`voucher` 值必须等于对应字段的 SUM,新增行同理。
|
||||
|
||||
**Validates: Requirements 7.2, 7.3**
|
||||
|
||||
### Property 7: 消费行细分列始终为 0
|
||||
|
||||
*For any* 调用 `get_finance_recharge()` 的返回结果,`gift_rows` 中消费行(label="消费")的 `liquor.value`、`table_fee.value`、`voucher.value` 必须为 0。`total.value` 可以为任意非负值。
|
||||
|
||||
**Validates: Requirements 7.4, 8.1, 8.2**
|
||||
|
||||
### Property 8: 环比计算对新字段正确适配
|
||||
|
||||
*For any* 两组随机生成的当期和上期 DWS 数据,`_build_recharge()` 在 `compare=1` 时,`gift_rows` 中每个 cell 的 `compare` 字段必须等于 `calc_compare(当期值, 上期值)` 的结果。
|
||||
|
||||
**Validates: Requirements 7.6**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### ETL 层
|
||||
|
||||
| 场景 | 处理策略 |
|
||||
|------|----------|
|
||||
| DWD 层某种卡类型无记录 | 对应字段写入 0(`dict.get()` 默认值) |
|
||||
| `_extract_gift_recharge_breakdown()` SQL 查询失败 | 由 ETL 框架统一捕获异常,任务标记失败,不写入脏数据 |
|
||||
| `dim_member_card_account` 中出现未知 `card_type_id` | 忽略(仅处理 `GIFT_CARD_TYPE_IDS` 中的三种) |
|
||||
| 余额恒等式不满足 | ETL 不做运行时校验(由数据质量检查任务事后验证) |
|
||||
|
||||
### 后端层
|
||||
|
||||
| 场景 | 处理策略 |
|
||||
|------|----------|
|
||||
| FDW 查询失败 | `_build_recharge()` 捕获异常,降级返回 `None`(现有逻辑) |
|
||||
| 查询结果为空 | 返回 `_empty_recharge_data()`(全 0 结构) |
|
||||
| 新字段在视图中不存在(迁移未执行) | SQL 报错,降级为 `None` |
|
||||
|
||||
### 小程序层
|
||||
|
||||
| 场景 | 处理策略 |
|
||||
|------|----------|
|
||||
| API 返回错误或超时 | 展示加载失败提示,不显示 mock 数据 |
|
||||
| API 返回的 `gift_rows` 结构不完整 | 使用默认值 0 填充缺失字段 |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 属性测试(Property-Based Testing)
|
||||
|
||||
使用 `hypothesis` 库(项目已有依赖),测试文件放置在 `tests/` 目录(Monorepo 级属性测试)。
|
||||
|
||||
每个属性测试最少 100 次迭代,使用 `@settings(max_examples=100)` 配置。
|
||||
|
||||
#### 测试文件
|
||||
|
||||
`tests/test_gift_card_breakdown_properties.py`
|
||||
|
||||
#### 属性测试清单
|
||||
|
||||
| 属性 | 测试方法 | 生成器 |
|
||||
|------|----------|--------|
|
||||
| P1: 余额恒等式 | 生成随机三种余额,验证总额 = 三者之和 | `st.decimals(min_value=0, max_value=999999, places=2)` |
|
||||
| P2: 新增恒等式 | 生成随机三种新增,验证总额 = 三者之和 | 同上 |
|
||||
| P3: ETL 余额提取 | 生成随机 card_account 行,mock DB,验证提取结果 | `st.lists(st.fixed_dictionaries({...}))` |
|
||||
| P4: ETL 新增提取 | 生成随机 recharge_order + card_account,mock DB,验证提取结果 | 同上 |
|
||||
| P5: transform 合并 | 生成随机 extracted dict,验证 record 包含正确字段 | `st.fixed_dictionaries({...})` |
|
||||
| P6: 后端接口细分数据 | 生成随机 DWS 行,mock FDW 查询,验证 gift_rows | `st.fixed_dictionaries({...})` |
|
||||
| P7: 消费行始终为 0 | 生成随机数据,验证消费行细分列 = 0 | 任意输入 |
|
||||
| P8: 环比适配 | 生成随机当期/上期数据,验证 compare 计算 | `st.tuples(amount_strategy, amount_strategy)` |
|
||||
|
||||
标签格式:`# Feature: gift-card-breakdown, Property {N}: {title}`
|
||||
|
||||
### 单元测试
|
||||
|
||||
使用 `pytest`,测试文件放置在各模块的 `tests/` 目录。
|
||||
|
||||
#### ETL 单元测试
|
||||
|
||||
`apps/etl/connectors/feiqiu/tests/unit/test_finance_recharge_task.py`
|
||||
|
||||
| 测试用例 | 说明 |
|
||||
|----------|------|
|
||||
| `test_extract_card_balances_with_all_types` | 三种卡类型都有记录时返回正确余额 |
|
||||
| `test_extract_card_balances_missing_type` | 某种卡类型无记录时返回 0 |
|
||||
| `test_extract_gift_recharge_breakdown_basic` | 基本新增拆分正确 |
|
||||
| `test_extract_gift_recharge_breakdown_empty` | 无充值记录时全部返回 0 |
|
||||
| `test_transform_includes_new_fields` | transform 输出包含 6 个新字段 |
|
||||
|
||||
#### 后端单元测试
|
||||
|
||||
`apps/backend/tests/test_fdw_queries_gift.py`
|
||||
|
||||
| 测试用例 | 说明 |
|
||||
|----------|------|
|
||||
| `test_get_finance_recharge_gift_breakdown` | 返回正确的细分数据 |
|
||||
| `test_get_finance_recharge_empty` | 无数据时返回全 0 |
|
||||
| `test_gift_rows_consumption_always_zero` | 消费行细分列始终为 0 |
|
||||
|
||||
### 集成验证(手动)
|
||||
|
||||
ETL 跑数后执行验证 SQL:
|
||||
|
||||
```sql
|
||||
-- 验证余额恒等式
|
||||
SELECT site_id, stat_date,
|
||||
gift_card_balance,
|
||||
gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance AS sum_breakdown,
|
||||
gift_card_balance - (gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance) AS diff
|
||||
FROM dws.dws_finance_recharge_summary
|
||||
WHERE gift_card_balance != gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance;
|
||||
|
||||
-- 验证新增恒等式
|
||||
SELECT site_id, stat_date,
|
||||
recharge_gift,
|
||||
gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge AS sum_breakdown,
|
||||
recharge_gift - (gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge) AS diff
|
||||
FROM dws.dws_finance_recharge_summary
|
||||
WHERE recharge_gift != gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge;
|
||||
|
||||
-- 验证与 DWD 源数据一致
|
||||
SELECT 'DWS' AS source,
|
||||
SUM(gift_liquor_balance) AS liquor,
|
||||
SUM(gift_table_fee_balance) AS table_fee,
|
||||
SUM(gift_voucher_balance) AS voucher
|
||||
FROM dws.dws_finance_recharge_summary
|
||||
WHERE stat_date = CURRENT_DATE
|
||||
UNION ALL
|
||||
SELECT 'DWD',
|
||||
SUM(CASE WHEN card_type_id = 2794699703437125 THEN balance ELSE 0 END),
|
||||
SUM(CASE WHEN card_type_id = 2791990152417157 THEN balance ELSE 0 END),
|
||||
SUM(CASE WHEN card_type_id = 2793266846533445 THEN balance ELSE 0 END)
|
||||
FROM dwd.dim_member_card_account
|
||||
WHERE scd2_is_current = 1 AND COALESCE(is_delete, 0) = 0
|
||||
AND card_type_id IN (2794699703437125, 2791990152417157, 2793266846533445);
|
||||
```
|
||||
131
.kiro/specs/gift-card-breakdown/requirements.md
Normal file
131
.kiro/specs/gift-card-breakdown/requirements.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
BOARD-3 财务看板的「预收资产」板块包含赠送卡 3×4 矩阵(3 行:新增/消费/余额;4 列:合计/酒水卡/台费卡/抵用券)。当前矩阵除余额行的 total 列外,所有细分单元格均返回 0。
|
||||
|
||||
根因:DWS 层 `dws_finance_recharge_summary` 只存储赠送卡总额(`recharge_gift`、`gift_card_balance`),未按卡类型拆分。DWD 层 `dim_member_card_account` 已通过 `card_type_id` 区分三种赠送卡类型(酒水卡、台费卡、抵用券),数据源完备。
|
||||
|
||||
本需求在 DWS 层新增 6 个字段(3 种卡类型 × 余额+新增),修改 ETL 拆分填充逻辑,更新数据库视图层,修改后端接口返回细分数据,并完成小程序联调,使赠送卡矩阵正确展示按卡类型拆分的数据。
|
||||
|
||||
关键约束:消费行无法按卡类型拆分(上游飞球 API 的结算单 `dwd_settlement_head.gift_card_amount` 仅提供赠送卡消费总额,不提供按卡类型的消费明细)。
|
||||
|
||||
## Glossary
|
||||
|
||||
- **DWS_Finance_Recharge_Summary**: DWS 层充值汇总表(`dws.dws_finance_recharge_summary`),按 `site_id` + `summary_date` 存储每日充值/余额汇总数据
|
||||
- **ETL_Finance_Recharge_Task**: ETL 任务类 `FinanceRechargeTask`,负责从 DWD 层提取数据并写入 DWS 层充值汇总表
|
||||
- **Gift_Card_Matrix**: 赠送卡矩阵,BOARD-3 财务看板中的 3×4 数据表格(行:新增/消费/余额;列:合计/酒水卡/台费卡/抵用券)
|
||||
- **DWD_Dim_Member_Card_Account**: DWD 层会员卡账户维度表(`dwd.dim_member_card_account`),通过 `card_type_id` 区分卡类型
|
||||
- **DWD_Recharge_Order**: DWD 层充值订单表(`dwd.dwd_recharge_order`),包含充值金额 `point_amount`(赠送部分)
|
||||
- **RLS_View**: 行级安全视图 `app.v_dws_finance_recharge_summary`,基于 `site_id` 实现多门店数据隔离
|
||||
- **FDW_Foreign_Table**: 通过 `postgres_fdw` 从 ETL 库映射到业务库的外部表
|
||||
- **Finance_Board_API**: FastAPI 接口 `GET /api/xcx/board/finance`,返回财务看板数据(含 `RechargePanel.gift_rows`)
|
||||
- **Board_Finance_Page**: 微信小程序页面 `board-finance`,渲染财务看板赠送卡矩阵
|
||||
- **FDW_Queries_Service**: 后端服务 `fdw_queries.py`,封装 FDW 外部表的 SQL 查询逻辑
|
||||
- **Card_Type_ID**: 飞球系统中区分卡类型的标识符,三种赠送卡 ID 已在 ETL 中硬编码为 `GIFT_CARD_TYPE_IDS`
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: DWS 层赠送卡细分字段扩展
|
||||
|
||||
**User Story:** 作为数据工程师,我需要在 DWS 充值汇总表中新增赠送卡按卡类型拆分的字段,以便下游查询能获取细分数据。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN DDL 迁移脚本执行完成, THE DWS_Finance_Recharge_Summary SHALL 包含以下 6 个新增字段:`gift_liquor_balance`(NUMERIC(14,2))、`gift_table_fee_balance`(NUMERIC(14,2))、`gift_voucher_balance`(NUMERIC(14,2))、`gift_liquor_recharge`(NUMERIC(14,2))、`gift_table_fee_recharge`(NUMERIC(14,2))、`gift_voucher_recharge`(NUMERIC(14,2))
|
||||
2. THE DWS_Finance_Recharge_Summary SHALL 对所有 6 个新增字段设置默认值为 0
|
||||
3. WHEN 新增字段写入完成, THE DWS_Finance_Recharge_Summary SHALL 满足余额恒等式:`gift_card_balance = gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance`
|
||||
4. WHEN 新增字段写入完成, THE DWS_Finance_Recharge_Summary SHALL 满足新增恒等式:`recharge_gift = gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge`
|
||||
|
||||
### Requirement 2: ETL 赠送卡余额按卡类型拆分
|
||||
|
||||
**User Story:** 作为数据工程师,我需要 ETL 任务按卡类型拆分赠送卡余额,以便 DWS 层存储各类型赠送卡的当日末余额。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN ETL_Finance_Recharge_Task 执行余额提取, THE ETL_Finance_Recharge_Task SHALL 从 DWD_Dim_Member_Card_Account 按 `card_type_id` 分组查询三种赠送卡(酒水卡 2794699703437125、台费卡 2791990152417157、抵用券 2793266846533445)的余额合计
|
||||
2. WHEN ETL_Finance_Recharge_Task 完成余额提取, THE ETL_Finance_Recharge_Task SHALL 将三种赠送卡余额分别写入 `gift_liquor_balance`、`gift_table_fee_balance`、`gift_voucher_balance` 字段
|
||||
3. IF DWD_Dim_Member_Card_Account 中某种赠送卡类型无记录, THEN THE ETL_Finance_Recharge_Task SHALL 将该类型余额字段写入 0
|
||||
|
||||
### Requirement 3: ETL 赠送卡新增充值按卡类型拆分
|
||||
|
||||
**User Story:** 作为数据工程师,我需要 ETL 任务按卡类型拆分赠送卡新增充值金额,以便 DWS 层存储各类型赠送卡的每日新增数据。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN ETL_Finance_Recharge_Task 执行新增充值提取, THE ETL_Finance_Recharge_Task SHALL 通过 DWD_Recharge_Order JOIN DWD_Dim_Member_Card_Account(关联 `tenant_member_card_id` → `tenant_member_id`)按 `card_type_id` 分组查询三种赠送卡的 `point_amount` 合计
|
||||
2. WHEN ETL_Finance_Recharge_Task 完成新增充值提取, THE ETL_Finance_Recharge_Task SHALL 将三种赠送卡新增金额分别写入 `gift_liquor_recharge`、`gift_table_fee_recharge`、`gift_voucher_recharge` 字段
|
||||
3. IF DWD_Recharge_Order 中某种赠送卡类型在指定日期无充值记录, THEN THE ETL_Finance_Recharge_Task SHALL 将该类型新增字段写入 0
|
||||
|
||||
### Requirement 4: ETL Transform 阶段写入细分字段
|
||||
|
||||
**User Story:** 作为数据工程师,我需要 ETL 的 transform 阶段将提取到的细分数据正确写入 record dict,以便 load 阶段持久化到 DWS 表。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN ETL_Finance_Recharge_Task 执行 transform, THE ETL_Finance_Recharge_Task SHALL 将余额提取结果(3 个字段)和新增充值提取结果(3 个字段)合并写入当日 record dict
|
||||
2. WHEN ETL_Finance_Recharge_Task 执行 transform 且提取方法返回空结果, THE ETL_Finance_Recharge_Task SHALL 将对应字段填充为 0
|
||||
3. THE ETL_Finance_Recharge_Task SHALL 使用与现有字段一致的 delete-before-insert 幂等策略写入 6 个新增字段
|
||||
|
||||
### Requirement 5: RLS 视图同步新增字段
|
||||
|
||||
**User Story:** 作为数据工程师,我需要 RLS 视图包含新增的 6 个字段,以便业务库通过视图访问细分数据并保持行级安全隔离。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN RLS_View 重建完成, THE RLS_View SHALL 包含 DWS_Finance_Recharge_Summary 的全部 6 个新增字段
|
||||
2. THE RLS_View SHALL 对新增字段保持与现有字段一致的 `site_id` 行级安全过滤策略
|
||||
3. WHEN 非授权门店用户查询 RLS_View, THE RLS_View SHALL 不返回其他门店的赠送卡细分数据
|
||||
|
||||
### Requirement 6: FDW 外部表同步
|
||||
|
||||
**User Story:** 作为数据工程师,我需要 FDW 外部表同步新增字段,以便后端应用通过业务库访问 DWS 层的赠送卡细分数据。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN `IMPORT FOREIGN SCHEMA` 重新执行, THE FDW_Foreign_Table SHALL 包含 DWS_Finance_Recharge_Summary 的全部 6 个新增字段
|
||||
2. THE FDW_Foreign_Table SHALL 通过幂等脚本完成同步,支持重复执行不报错
|
||||
|
||||
### Requirement 7: 后端接口返回赠送卡细分数据
|
||||
|
||||
**User Story:** 作为门店管理者,我需要财务看板接口返回赠送卡按卡类型拆分的数据,以便小程序展示完整的赠送卡矩阵。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN Finance_Board_API 被调用, THE FDW_Queries_Service SHALL 在 SQL 查询中包含 6 个新增字段的 SUM 聚合
|
||||
2. WHEN Finance_Board_API 返回 `gift_rows`, THE Finance_Board_API SHALL 在余额行的 `liquor`、`table_fee`、`voucher` 列返回对应的细分余额数值(非 0)
|
||||
3. WHEN Finance_Board_API 返回 `gift_rows`, THE Finance_Board_API SHALL 在新增行的 `liquor`、`table_fee`、`voucher` 列返回对应的细分新增数值(非 0,当日有充值时)
|
||||
4. WHEN Finance_Board_API 返回 `gift_rows` 且消费行无法按卡类型拆分, THE Finance_Board_API SHALL 在消费行的 `liquor`、`table_fee`、`voucher` 列返回 0,仅 `total` 列返回消费总额
|
||||
5. WHEN Finance_Board_API 被调用且无数据, THE FDW_Queries_Service SHALL 返回所有新增字段默认值为 0 的空数据结构
|
||||
6. WHEN Finance_Board_API 被调用且 compare 参数启用, THE Finance_Board_API SHALL 对赠送卡细分字段正确计算环比数据
|
||||
|
||||
### Requirement 8: 消费行数据处理策略
|
||||
|
||||
**User Story:** 作为门店管理者,我需要了解赠送卡消费行的数据展示策略,以便正确理解矩阵中消费数据的含义。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Finance_Board_API SHALL 在赠送卡矩阵消费行的 `total` 列返回赠送卡消费总额(来源 `dwd_settlement_head.gift_card_amount` 汇总)
|
||||
2. WHILE 上游飞球 API 不提供按卡类型拆分的消费明细, THE Finance_Board_API SHALL 在消费行的 `liquor`、`table_fee`、`voucher` 列返回 0
|
||||
3. IF 未来上游 API 提供按卡类型拆分的消费明细, THEN THE ETL_Finance_Recharge_Task SHALL 扩展消费拆分字段(不在本次需求范围内)
|
||||
|
||||
### Requirement 9: 小程序赠送卡矩阵渲染
|
||||
|
||||
**User Story:** 作为门店管理者,我需要在小程序财务看板中看到赠送卡矩阵正确展示后端返回的细分数据,以便了解各类赠送卡的使用情况。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN Board_Finance_Page 加载完成, THE Board_Finance_Page SHALL 从 Finance_Board_API 获取真实数据替换当前 mock 数据
|
||||
2. WHEN Board_Finance_Page 渲染赠送卡矩阵, THE Board_Finance_Page SHALL 按字段映射关系正确展示数据:`liquor` → 酒水卡列、`table_fee` → 台费卡列、`voucher` → 抵用券列
|
||||
3. WHEN Finance_Board_API 返回环比数据, THE Board_Finance_Page SHALL 在对应单元格展示环比变化标识
|
||||
4. IF Finance_Board_API 返回错误或超时, THEN THE Board_Finance_Page SHALL 展示加载失败提示,不显示 mock 数据
|
||||
|
||||
### Requirement 10: 数据一致性验证
|
||||
|
||||
**User Story:** 作为数据工程师,我需要验证 ETL 写入的细分数据与 DWD 层源数据一致,以便确保数据准确性。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN ETL 跑数完成, THE DWS_Finance_Recharge_Summary SHALL 满足:三种赠送卡余额之和(`gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance`)等于 DWD_Dim_Member_Card_Account 中对应三种 `card_type_id` 的 `balance` 之和
|
||||
2. WHEN ETL 跑数完成, THE DWS_Finance_Recharge_Summary SHALL 满足:三种赠送卡新增之和(`gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge`)等于 DWD_Recharge_Order 中对应三种卡类型当日 `point_amount` 之和
|
||||
3. THE DWS_Finance_Recharge_Summary SHALL 满足:`gift_card_balance` 字段值等于 `gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance`(总额与细分之和恒等)
|
||||
147
.kiro/specs/gift-card-breakdown/tasks.md
Normal file
147
.kiro/specs/gift-card-breakdown/tasks.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Implementation Plan: 赠送卡矩阵细分数据 (gift-card-breakdown)
|
||||
|
||||
## Overview
|
||||
|
||||
贯穿全栈数据链路的改动:DDL 新增 6 字段 → ETL 拆分填充 → RLS 视图 + FDW 同步 → 后端 SQL + 接口返回 → 小程序替换 mock。消费行因上游 API 限制,细分列保持 0。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. DDL 迁移与基线同步
|
||||
- [x] 1.1 创建 DDL 迁移脚本 `db/etl_feiqiu/migrations/2026-xx-xx_add_gift_breakdown_fields.sql`
|
||||
- ALTER TABLE 新增 6 个 NUMERIC(14,2) 字段,NOT NULL DEFAULT 0
|
||||
- 使用 `ADD COLUMN IF NOT EXISTS` 保证幂等
|
||||
- _Requirements: 1.1, 1.2_
|
||||
- [x] 1.2 同步 DDL 基线文件 `docs/database/ddl/etl_feiqiu__dws.sql`
|
||||
- 在 `dws_finance_recharge_summary` 表定义中追加 6 个新字段
|
||||
- _Requirements: 1.1_
|
||||
|
||||
- [x] 2. ETL 赠送卡余额拆分
|
||||
- [x] 2.1 修改 `_extract_card_balances()` 按 card_type_id 分组返回细分余额
|
||||
- 文件:`apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py`
|
||||
- 新增 `GIFT_TYPE_FIELD_MAP` 常量映射 card_type_id → 字段名
|
||||
- 返回值新增 `gift_liquor_balance`、`gift_table_fee_balance`、`gift_voucher_balance`
|
||||
- 保留原有 `gift_balance` 字段(向后兼容)
|
||||
- 某种卡类型无记录时对应字段返回 0
|
||||
- _Requirements: 2.1, 2.2, 2.3_
|
||||
- [x] 2.2 编写属性测试:ETL 余额提取 round-trip
|
||||
- **Property 3: ETL 余额提取 round-trip**
|
||||
- 生成随机 `dim_member_card_account` 记录,mock DB,验证各类型余额等于对应 card_type_id 的 balance 之和
|
||||
- 当某种卡类型无记录时,对应余额为 0
|
||||
- **Validates: Requirements 2.1, 2.2, 2.3, 10.1**
|
||||
|
||||
- [ ] 3. ETL 赠送卡新增充值拆分
|
||||
- [x] 3.1 新增 `_extract_gift_recharge_breakdown()` 方法
|
||||
- 文件:`apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py`
|
||||
- SQL:`dwd_recharge_order JOIN dim_member_card_account`(`tenant_member_card_id` → `tenant_member_id`)按 card_type_id 分组
|
||||
- 新增 `GIFT_RECHARGE_FIELD_MAP` 常量映射
|
||||
- 返回 `{gift_liquor_recharge, gift_table_fee_recharge, gift_voucher_recharge}`,缺失卡类型默认 0
|
||||
- _Requirements: 3.1, 3.2, 3.3_
|
||||
- [-] 3.2 编写属性测试:ETL 新增提取 round-trip
|
||||
- **Property 4: ETL 新增提取 round-trip**
|
||||
- 生成随机 `dwd_recharge_order` + `dim_member_card_account` 记录,mock DB,验证各类型新增等于对应 card_type_id 的 point_amount 之和
|
||||
- 当某种卡类型无充值记录时,对应新增为 0
|
||||
- **Validates: Requirements 3.1, 3.2, 3.3, 10.2**
|
||||
|
||||
- [ ] 4. ETL transform 合并细分字段
|
||||
- [~] 4.1 修改 `extract()` 调用新方法并传递结果
|
||||
- 文件:`apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py`
|
||||
- 在 extract 返回值中新增 `gift_recharge_breakdown` key
|
||||
- _Requirements: 4.1_
|
||||
- [~] 4.2 修改 `transform()` 将 6 个新字段写入 record dict
|
||||
- 使用 `self.safe_decimal()` 处理值,缺失 key 时填充 0
|
||||
- 沿用现有 delete-before-insert 幂等策略
|
||||
- _Requirements: 4.1, 4.2, 4.3_
|
||||
- [~] 4.3 编写属性测试:transform 正确合并细分字段
|
||||
- **Property 5: transform 正确合并细分字段**
|
||||
- 生成随机 `card_balances` dict 和 `gift_recharge_breakdown` dict,验证 record 包含 6 个字段且值正确
|
||||
- 输入 dict 缺少某个 key 时,对应字段为 0
|
||||
- **Validates: Requirements 4.1, 4.2**
|
||||
|
||||
- [ ] 5. Checkpoint — ETL 层验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 6. 数据库视图层同步
|
||||
- [~] 6.1 RLS 视图重建 `app.v_dws_finance_recharge_summary`
|
||||
- 创建迁移脚本 `db/zqyy_app/migrations/` 下
|
||||
- `CREATE OR REPLACE VIEW` 包含全部 6 个新字段
|
||||
- 保持 `site_id` 行级安全过滤策略不变
|
||||
- _Requirements: 5.1, 5.2, 5.3_
|
||||
- [~] 6.2 FDW 外部表同步
|
||||
- 创建/更新幂等脚本(先 DROP 再 `IMPORT FOREIGN SCHEMA`)
|
||||
- 支持重复执行不报错
|
||||
- _Requirements: 6.1, 6.2_
|
||||
|
||||
- [ ] 7. 后端接口修改
|
||||
- [~] 7.1 修改 `fdw_queries.get_finance_recharge()` SQL 查询
|
||||
- 文件:`apps/backend/app/services/fdw_queries.py`
|
||||
- SQL 新增 6 个字段的 SUM 聚合
|
||||
- _Requirements: 7.1_
|
||||
- [~] 7.2 修改 `gift_rows` 构建逻辑
|
||||
- 余额行:`liquor`/`table_fee`/`voucher` 填充对应细分余额
|
||||
- 新增行:`liquor`/`table_fee`/`voucher` 填充对应细分新增,`total` 使用三个细分之和
|
||||
- 消费行:`liquor`/`table_fee`/`voucher` 保持 0,`total` 返回消费总额
|
||||
- _Requirements: 7.2, 7.3, 7.4, 8.1, 8.2_
|
||||
- [~] 7.3 修改 `_empty_recharge_data()` 空默认值同步
|
||||
- 确保新增 6 个字段在空数据结构中默认为 0
|
||||
- _Requirements: 7.5_
|
||||
- [~] 7.4 编写属性测试:余额恒等式
|
||||
- **Property 1: 余额恒等式**
|
||||
- 生成随机三种余额,验证 `gift_card_balance = gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance`
|
||||
- **Validates: Requirements 1.3, 10.3**
|
||||
- [~] 7.5 编写属性测试:新增恒等式
|
||||
- **Property 2: 新增恒等式**
|
||||
- 生成随机三种新增,验证 `recharge_gift = gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge`
|
||||
- **Validates: Requirements 1.4**
|
||||
- [~] 7.6 编写属性测试:后端接口返回正确细分数据
|
||||
- **Property 6: 后端接口返回正确的细分数据**
|
||||
- 生成随机 DWS 行,mock FDW 查询,验证 gift_rows 余额行和新增行的细分值等于对应字段 SUM
|
||||
- **Validates: Requirements 7.2, 7.3**
|
||||
- [~] 7.7 编写属性测试:消费行细分列始终为 0
|
||||
- **Property 7: 消费行细分列始终为 0**
|
||||
- 验证 gift_rows 消费行的 `liquor.value`、`table_fee.value`、`voucher.value` 始终为 0
|
||||
- **Validates: Requirements 7.4, 8.1, 8.2**
|
||||
- [~] 7.8 编写属性测试:环比计算对新字段正确适配
|
||||
- **Property 8: 环比计算对新字段正确适配**
|
||||
- 生成随机当期/上期数据,验证 `compare=1` 时 gift_rows 每个 cell 的 compare 等于 `calc_compare(当期值, 上期值)`
|
||||
- **Validates: Requirements 7.6**
|
||||
|
||||
- [ ] 8. Checkpoint — 后端层验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 9. 小程序联调
|
||||
- [~] 9.1 替换 `board-finance.ts` 中 mock 数据为真实 API 调用
|
||||
- 文件:`apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts`
|
||||
- 移除 `giftRows` 硬编码 mock 数据
|
||||
- 使用 Finance_Board_API 返回的真实数据
|
||||
- 字段映射:`liquor→wine`、`table_fee→table`、`voucher→coupon`,在数据转换层处理
|
||||
- API 返回错误或超时时展示加载失败提示,不显示 mock 数据
|
||||
- _Requirements: 9.1, 9.2, 9.3, 9.4_
|
||||
|
||||
- [ ] 10. 数据一致性验证
|
||||
- [~] 10.1 创建验证 SQL 脚本
|
||||
- 验证余额恒等式:`gift_card_balance = 三种余额之和`
|
||||
- 验证新增恒等式:`recharge_gift = 三种新增之和`
|
||||
- 验证 DWS 与 DWD 源数据一致:DWS 各类型余额 = DWD `dim_member_card_account` 对应 card_type_id 的 balance 之和
|
||||
- 脚本放置在 `scripts/ops/` 或迁移目录
|
||||
- _Requirements: 10.1, 10.2, 10.3_
|
||||
|
||||
- [ ] 11. BD 手册更新
|
||||
- [~] 11.1 更新 `BD_manual_dws_finance_recharge_summary.md`
|
||||
- 文件:`apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_finance_recharge_summary.md`
|
||||
- 新增 6 个字段的说明(字段名、类型、含义、数据来源)
|
||||
- 更新恒等式约束说明
|
||||
- 更新数据流向描述
|
||||
- _Requirements: 1.1, 1.3, 1.4_
|
||||
|
||||
- [ ] 12. Final checkpoint — 全链路验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- 设计文档使用 Python(ETL)、SQL(DDL/查询)、TypeScript(小程序),任务中代码示例沿用对应语言
|
||||
- 消费行因上游飞球 API 限制(`dwd_settlement_head.gift_card_amount` 仅提供总额),细分列保持 0
|
||||
- 属性测试使用 `hypothesis` 库,测试文件统一放置在 `tests/test_gift_card_breakdown_properties.py`
|
||||
- 单元测试放置在各模块的 `tests/` 目录下
|
||||
- 所有 DDL 迁移脚本使用 `IF NOT EXISTS` 保证幂等
|
||||
- card_type_id 硬编码沿用现有 `GIFT_CARD_TYPE_IDS` 常量
|
||||
1
.kiro/specs/rns1-board-apis/.config.kiro
Normal file
1
.kiro/specs/rns1-board-apis/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "a3f7c2d1-8e4b-4f6a-9c5d-2b1e8f3a7d9c", "workflowType": "requirements-first", "specType": "feature"}
|
||||
840
.kiro/specs/rns1-board-apis/design.md
Normal file
840
.kiro/specs/rns1-board-apis/design.md
Normal file
@@ -0,0 +1,840 @@
|
||||
# 设计文档 — RNS1.3:三看板接口
|
||||
|
||||
## 概述
|
||||
|
||||
本设计覆盖 RNS1.3 的全部后端接口(BOARD-1/2/3、CONFIG-1)和前端筛选修复。核心挑战在于 BOARD-3 财务看板的复杂度(6 板块、200+ 字段、60+ 环比数据点)以及跨多个 ETL RLS 视图的数据聚合。
|
||||
|
||||
设计遵循已有架构模式:
|
||||
- 路由层(`routers/`):参数校验 + 权限检查,委托 service 层
|
||||
- 服务层(`services/`):业务逻辑编排,调用 `fdw_queries` 查询 ETL 数据
|
||||
- Schema 层(`schemas/`):`CamelModel` 基类,自动 camelCase 转换
|
||||
- 中间件:`ResponseWrapperMiddleware` 统一包装 `{ code: 0, data: ... }`
|
||||
- FDW 查询:`_fdw_context` 上下文管理器,直连 ETL 库 + `SET LOCAL app.current_site_id`
|
||||
|
||||
### 设计决策
|
||||
|
||||
1. **BOARD-1 扁平返回 vs 按维度返回**:选择扁平返回(所有维度字段一次性返回),前端切换维度无需重新请求,减少网络往返。代价是单次响应略大,但助教数量有限(通常 < 50),可接受。
|
||||
2. **BOARD-2 按维度返回**:选择按 `dimension` 参数仅返回对应维度字段。客户数量可达数千,分页 + 维度专属字段可显著减少传输量和查询开销。
|
||||
3. **BOARD-3 单接口 6 板块**:单个 `GET /api/xcx/board/finance` 返回全部 6 个板块。各板块独立查询、独立降级,某板块失败不影响其他板块。`recharge` 板块在 `area≠all` 时返回 `null`。
|
||||
4. **环比计算后端统一处理**:`compare=1` 时后端计算所有环比字段,`compare=0` 时完全不返回环比字段(非返回 null,而是字段不存在),减少 JSON 体积。
|
||||
5. **FDW 查询集中封装**:所有新增 ETL 查询函数统一添加到 `fdw_queries.py`,保持 DWD-DOC 规则在单一模块实施。
|
||||
|
||||
## 架构
|
||||
|
||||
### 请求流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MP as 小程序
|
||||
participant MW as ResponseWrapperMiddleware
|
||||
participant R as Router (xcx_board / xcx_config)
|
||||
participant P as Permission (require_permission)
|
||||
participant S as Service (board_service / config_service)
|
||||
participant FDW as fdw_queries (_fdw_context)
|
||||
participant ETL as ETL DB (app.v_*)
|
||||
participant BIZ as App DB (biz.*)
|
||||
|
||||
MP->>MW: GET /api/xcx/board/coaches?sort=perf_desc&skill=all&time=month
|
||||
MW->>R: 透传请求
|
||||
R->>P: require_permission("view_board_coach")
|
||||
P-->>R: CurrentUser (site_id)
|
||||
R->>S: get_coach_board(params, site_id)
|
||||
S->>FDW: 查询助教列表 + 绩效 + 客户
|
||||
FDW->>ETL: SET LOCAL app.current_site_id; SELECT FROM app.v_*
|
||||
ETL-->>FDW: 结果集
|
||||
S->>BIZ: 查询任务完成数 (biz.coach_tasks)
|
||||
BIZ-->>S: 结果集
|
||||
S-->>R: 组装响应 dict
|
||||
R-->>MW: JSON 响应
|
||||
MW-->>MP: { code: 0, data: {...} }
|
||||
```
|
||||
|
||||
### 模块结构
|
||||
|
||||
```
|
||||
apps/backend/app/
|
||||
├── routers/
|
||||
│ ├── xcx_board.py # 新增:BOARD-1/2/3 三个看板端点
|
||||
│ └── xcx_config.py # 新增:CONFIG-1 技能类型端点
|
||||
├── schemas/
|
||||
│ ├── xcx_board.py # 新增:三看板请求参数 + 响应 schema
|
||||
│ └── xcx_config.py # 新增:技能类型响应 schema
|
||||
├── services/
|
||||
│ ├── board_service.py # 新增:看板业务逻辑(3 个看板的编排函数)
|
||||
│ └── fdw_queries.py # 扩展:新增看板相关 FDW 查询函数
|
||||
└── main.py # 扩展:注册 xcx_board / xcx_config 路由
|
||||
|
||||
apps/miniprogram/miniprogram/
|
||||
├── pages/board-coach/ # 修改:筛选事件 → loadData()
|
||||
├── pages/board-customer/ # 修改:筛选事件 → loadData() + 分页
|
||||
├── pages/board-finance/ # 修改:筛选事件 → loadData() + 环比开关
|
||||
└── services/api.ts # 修改:函数签名扩展
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 3.1 路由层 — `xcx_board.py`
|
||||
|
||||
三个端点共用一个路由文件,前缀 `/api/xcx/board`。
|
||||
|
||||
```python
|
||||
# GET /api/xcx/board/coaches
|
||||
@router.get("/coaches", response_model=CoachBoardResponse)
|
||||
async def get_coach_board(
|
||||
sort: CoachSortEnum = Query(default="perf_desc"),
|
||||
skill: SkillFilterEnum = Query(default="all"),
|
||||
time: BoardTimeEnum = Query(default="month"),
|
||||
user: CurrentUser = Depends(require_permission("view_board_coach")),
|
||||
): ...
|
||||
|
||||
# GET /api/xcx/board/customers
|
||||
@router.get("/customers", response_model=CustomerBoardResponse)
|
||||
async def get_customer_board(
|
||||
dimension: CustomerDimensionEnum = Query(default="recall"),
|
||||
project: ProjectFilterEnum = Query(default="all"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_permission("view_board_customer")),
|
||||
): ...
|
||||
|
||||
# GET /api/xcx/board/finance
|
||||
@router.get("/finance", response_model=FinanceBoardResponse)
|
||||
async def get_finance_board(
|
||||
time: FinanceTimeEnum = Query(default="month"),
|
||||
area: AreaFilterEnum = Query(default="all"),
|
||||
compare: int = Query(default=0, ge=0, le=1),
|
||||
user: CurrentUser = Depends(require_permission("view_board_finance")),
|
||||
): ...
|
||||
```
|
||||
|
||||
### 3.2 路由层 — `xcx_config.py`
|
||||
|
||||
```python
|
||||
# GET /api/xcx/config/skill-types
|
||||
@router.get("/skill-types", response_model=list[SkillTypeItem])
|
||||
async def get_skill_types(
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
): ...
|
||||
```
|
||||
|
||||
### 3.3 服务层 — `board_service.py`
|
||||
|
||||
三个核心编排函数,各自独立处理参数解析、日期范围计算、数据查询和响应组装:
|
||||
|
||||
```python
|
||||
async def get_coach_board(sort, skill, time, site_id) -> dict:
|
||||
"""BOARD-1:助教看板。扁平返回所有维度字段。"""
|
||||
|
||||
async def get_customer_board(dimension, project, page, page_size, site_id) -> dict:
|
||||
"""BOARD-2:客户看板。按维度返回专属字段 + 分页。"""
|
||||
|
||||
async def get_finance_board(time, area, compare, site_id) -> dict:
|
||||
"""BOARD-3:财务看板。6 板块独立查询、独立降级。"""
|
||||
```
|
||||
|
||||
### 3.4 FDW 查询层扩展 — `fdw_queries.py`
|
||||
|
||||
新增函数(遵循已有 `_fdw_context` 模式):
|
||||
|
||||
| 函数 | 数据源视图 | 用途 |
|
||||
|------|-----------|------|
|
||||
| `get_all_assistants` | `v_dim_assistant` | BOARD-1 助教列表 |
|
||||
| `get_salary_calc_batch` | `v_dws_assistant_salary_calc` | BOARD-1 批量绩效(基于已有 `get_salary_calc` SQL 模式扩展为批量查询) |
|
||||
| `get_top_customers_for_coaches` | `v_dws_member_assistant_relation_index` + `v_dim_member` | BOARD-1 Top 客户(基于已有 `get_relation_index` 扩展为按助教批量查询) |
|
||||
| `get_coach_sv_data` | `v_dws_assistant_monthly_summary` | BOARD-1 sv 维度(助教月度储值汇总,已按助教预聚合) |
|
||||
| `get_customer_board_recall` | `v_dws_member_winback_index` + `v_dim_member` | BOARD-2 recall 维度(ETL 已计算 WBI 指数) |
|
||||
| `get_customer_board_potential` | `v_dws_member_spending_power_index` | BOARD-2 potential 维度(ETL 已计算 SPI 指数) |
|
||||
| `get_customer_board_balance` | `v_dim_member_card_account` + `v_dim_member` | BOARD-2 balance 维度 |
|
||||
| `get_customer_board_recharge` | `v_dwd_recharge_order` + `v_dim_member_card_account` | BOARD-2 recharge 维度(充值记录 + 当前余额) |
|
||||
| `get_customer_board_recent` | `v_dws_member_visit_detail` + `v_dim_member` | BOARD-2 recent 维度(ETL 已计算到店明细) |
|
||||
| `get_customer_board_spend60` | `v_dws_member_consumption_summary` | BOARD-2 spend60 维度(items_sum_60d 已在汇总表中) |
|
||||
| `get_customer_board_freq60` | `v_dws_member_consumption_summary` | BOARD-2 freq60 维度(visit_count_60d 已在汇总表中) |
|
||||
| `get_customer_board_loyal` | `v_dws_member_assistant_relation_index` | BOARD-2 loyal 维度 |
|
||||
| `get_finance_overview` | `v_dws_finance_daily_summary` | BOARD-3 经营一览(按日期范围聚合财务日报) |
|
||||
| `get_finance_recharge` | `v_dws_finance_recharge_summary` | BOARD-3 预收资产 |
|
||||
| `get_finance_revenue` | `v_dws_finance_income_structure` + `v_dws_finance_discount_detail` | BOARD-3 应计收入 |
|
||||
| `get_finance_cashflow` | `v_dws_finance_daily_summary` | BOARD-3 现金流入(复用财务日报中的收款字段) |
|
||||
| `get_finance_expense` | `v_dws_finance_expense_summary` + `v_dws_platform_settlement` | BOARD-3 现金流出 |
|
||||
| `get_finance_coach_analysis` | `v_dws_assistant_salary_calc` | BOARD-3 助教分析 |
|
||||
| `get_skill_types` | ETL cfg 表 | CONFIG-1 技能类型 |
|
||||
|
||||
### 3.5 日期范围计算
|
||||
|
||||
统一工具函数 `_calc_date_range(time_enum) -> (start_date, end_date)`,返回 `date` 对象:
|
||||
|
||||
| time 参数 | 当期范围 | 上期范围(compare=1 时) |
|
||||
|-----------|---------|------------------------|
|
||||
| `month` | 当月 1 日 ~ 末日 | 上月 1 日 ~ 末日 |
|
||||
| `lastMonth` / `last_month` | 上月 1 日 ~ 末日 | 上上月 |
|
||||
| `week` | 本周一 ~ 本周日 | 上周一 ~ 上周日 |
|
||||
| `lastWeek` | 上周一 ~ 上周日 | 上上周 |
|
||||
| `quarter` | 本季度首日 ~ 末日 | 上季度 |
|
||||
| `lastQuarter` / `last_quarter` | 上季度 | 上上季度 |
|
||||
| `quarter3` / `last_3m` | 前 3 个月(不含本月) | 再前 3 个月 |
|
||||
| `half6` / `last_6m` | 前 6 个月(不含本月) | 再前 6 个月 |
|
||||
|
||||
### 3.6 环比计算工具
|
||||
|
||||
```python
|
||||
def calc_compare(current: Decimal, previous: Decimal) -> dict:
|
||||
"""
|
||||
统一环比计算。
|
||||
|
||||
返回:
|
||||
- compare: str — "12.5%" / "新增" / "持平"
|
||||
- is_down: bool — 是否下降
|
||||
- is_flat: bool — 是否持平
|
||||
|
||||
规则:
|
||||
- previous=0, current≠0 → "新增", is_down=False, is_flat=False
|
||||
- previous=0, current=0 → "持平", is_down=False, is_flat=True
|
||||
- 正常计算: (current - previous) / previous × 100%
|
||||
- 正值 → is_down=False; 负值 → is_down=True; 零 → is_flat=True
|
||||
"""
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 4.1 请求参数枚举
|
||||
|
||||
```python
|
||||
# BOARD-1 排序维度
|
||||
class CoachSortEnum(str, Enum):
|
||||
perf_desc = "perf_desc"
|
||||
perf_asc = "perf_asc"
|
||||
salary_desc = "salary_desc"
|
||||
salary_asc = "salary_asc"
|
||||
sv_desc = "sv_desc"
|
||||
task_desc = "task_desc"
|
||||
|
||||
# BOARD-1 技能筛选
|
||||
class SkillFilterEnum(str, Enum):
|
||||
all = "all"
|
||||
chinese = "chinese"
|
||||
snooker = "snooker"
|
||||
mahjong = "mahjong"
|
||||
karaoke = "karaoke"
|
||||
|
||||
# BOARD-1 时间范围
|
||||
class BoardTimeEnum(str, Enum):
|
||||
month = "month"
|
||||
quarter = "quarter"
|
||||
last_month = "last_month"
|
||||
last_3m = "last_3m"
|
||||
last_quarter = "last_quarter"
|
||||
last_6m = "last_6m"
|
||||
|
||||
# BOARD-2 客户维度
|
||||
class CustomerDimensionEnum(str, Enum):
|
||||
recall = "recall"
|
||||
potential = "potential"
|
||||
balance = "balance"
|
||||
recharge = "recharge"
|
||||
recent = "recent"
|
||||
spend60 = "spend60"
|
||||
freq60 = "freq60"
|
||||
loyal = "loyal"
|
||||
|
||||
# BOARD-2 项目筛选
|
||||
class ProjectFilterEnum(str, Enum):
|
||||
all = "all"
|
||||
chinese = "chinese"
|
||||
snooker = "snooker"
|
||||
mahjong = "mahjong"
|
||||
karaoke = "karaoke"
|
||||
|
||||
# BOARD-3 时间范围
|
||||
class FinanceTimeEnum(str, Enum):
|
||||
month = "month"
|
||||
lastMonth = "lastMonth"
|
||||
week = "week"
|
||||
lastWeek = "lastWeek"
|
||||
quarter3 = "quarter3"
|
||||
quarter = "quarter"
|
||||
lastQuarter = "lastQuarter"
|
||||
half6 = "half6"
|
||||
|
||||
# BOARD-3 区域筛选
|
||||
class AreaFilterEnum(str, Enum):
|
||||
all = "all"
|
||||
hall = "hall"
|
||||
hallA = "hallA"
|
||||
hallB = "hallB"
|
||||
hallC = "hallC"
|
||||
mahjong = "mahjong"
|
||||
teamBuilding = "teamBuilding"
|
||||
```
|
||||
|
||||
### 4.2 BOARD-1 响应 Schema
|
||||
|
||||
```python
|
||||
class CoachSkillItem(CamelModel):
|
||||
text: str
|
||||
cls: str
|
||||
|
||||
class CoachBoardItem(CamelModel):
|
||||
"""助教看板单条记录(扁平结构,包含所有维度字段)。"""
|
||||
# 基础字段(所有维度共享)
|
||||
id: int
|
||||
name: str
|
||||
initial: str
|
||||
avatar_gradient: str
|
||||
level: str # star/senior/middle/junior
|
||||
skills: list[CoachSkillItem]
|
||||
top_customers: list[str] # ["💖 王先生", "💛 李女士"]
|
||||
|
||||
# perf 维度
|
||||
perf_hours: float = 0.0
|
||||
perf_hours_before: float | None = None
|
||||
perf_gap: str | None = None # "距升档 13.8h" 或 None
|
||||
perf_reached: bool = False
|
||||
|
||||
# salary 维度
|
||||
salary: float = 0.0
|
||||
salary_perf_hours: float = 0.0
|
||||
salary_perf_before: float | None = None
|
||||
|
||||
# sv 维度
|
||||
sv_amount: float = 0.0
|
||||
sv_customer_count: int = 0
|
||||
sv_consume: float = 0.0
|
||||
|
||||
# task 维度
|
||||
task_recall: int = 0
|
||||
task_callback: int = 0
|
||||
|
||||
class CoachBoardResponse(CamelModel):
|
||||
items: list[CoachBoardItem]
|
||||
dim_type: str # perf/salary/sv/task
|
||||
```
|
||||
|
||||
### 4.3 BOARD-2 响应 Schema
|
||||
|
||||
```python
|
||||
class CustomerAssistant(CamelModel):
|
||||
name: str
|
||||
cls: str
|
||||
heart_score: float
|
||||
badge: str | None = None
|
||||
badge_cls: str | None = None
|
||||
|
||||
class CustomerBoardItemBase(CamelModel):
|
||||
"""客户看板基础字段(所有维度共享)。"""
|
||||
id: int
|
||||
name: str
|
||||
initial: str
|
||||
avatar_cls: str
|
||||
assistants: list[CustomerAssistant]
|
||||
|
||||
# 各维度专属字段通过继承扩展
|
||||
class RecallItem(CustomerBoardItemBase):
|
||||
ideal_days: int
|
||||
elapsed_days: int
|
||||
overdue_days: int
|
||||
visits_30d: int
|
||||
balance: str
|
||||
recall_index: float
|
||||
|
||||
class PotentialTag(CamelModel):
|
||||
text: str
|
||||
theme: str
|
||||
|
||||
class PotentialItem(CustomerBoardItemBase):
|
||||
potential_tags: list[PotentialTag]
|
||||
spend_30d: float
|
||||
avg_visits: float
|
||||
avg_spend: float
|
||||
|
||||
class BalanceItem(CustomerBoardItemBase):
|
||||
balance: str
|
||||
last_visit: str # "3天前"
|
||||
monthly_consume: float
|
||||
available_months: str # "约0.8个月"
|
||||
|
||||
class RechargeItem(CustomerBoardItemBase):
|
||||
last_recharge: str
|
||||
recharge_amount: float
|
||||
recharges_60d: int
|
||||
current_balance: str
|
||||
|
||||
class RecentItem(CustomerBoardItemBase):
|
||||
days_ago: int
|
||||
visit_freq: str # "6.2次/月"
|
||||
ideal_days: int
|
||||
visits_30d: int
|
||||
avg_spend: float
|
||||
|
||||
class Spend60Item(CustomerBoardItemBase):
|
||||
spend_60d: float
|
||||
visits_60d: int
|
||||
high_spend_tag: bool
|
||||
avg_spend: float
|
||||
|
||||
class WeeklyVisit(CamelModel):
|
||||
val: int
|
||||
pct: int # 0-100
|
||||
|
||||
class Freq60Item(CustomerBoardItemBase):
|
||||
visits_60d: int
|
||||
avg_interval: str # "5.0天"
|
||||
weekly_visits: list[WeeklyVisit] # 固定长度 8
|
||||
spend_60d: float
|
||||
|
||||
class CoachDetail(CamelModel):
|
||||
name: str
|
||||
cls: str
|
||||
heart_score: float
|
||||
badge: str | None = None
|
||||
avg_duration: str
|
||||
service_count: int
|
||||
coach_spend: float
|
||||
relation_idx: float
|
||||
|
||||
class LoyalItem(CustomerBoardItemBase):
|
||||
intimacy: float
|
||||
top_coach_name: str
|
||||
top_coach_heart: float
|
||||
top_coach_score: float
|
||||
coach_name: str
|
||||
coach_ratio: str # "78%"
|
||||
coach_details: list[CoachDetail]
|
||||
|
||||
class CustomerBoardResponse(CamelModel):
|
||||
items: list[dict] # 实际类型取决于 dimension
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
```
|
||||
|
||||
### 4.4 BOARD-3 响应 Schema
|
||||
|
||||
```python
|
||||
class CompareField(CamelModel):
|
||||
"""环比字段三元组(仅 compare=1 时出现)。"""
|
||||
compare: str # "12.5%" / "新增" / "持平"
|
||||
down: bool
|
||||
flat: bool
|
||||
|
||||
class OverviewPanel(CamelModel):
|
||||
occurrence: float
|
||||
discount: float # 负值
|
||||
discount_rate: float
|
||||
confirmed_revenue: float
|
||||
cash_in: float
|
||||
cash_out: float
|
||||
cash_balance: float
|
||||
balance_rate: float
|
||||
# 环比字段(compare=1 时存在,compare=0 时整个字段不出现)
|
||||
occurrence_compare: str | None = None
|
||||
occurrence_down: bool | None = None
|
||||
occurrence_flat: bool | None = None
|
||||
# ... 其余 7 项指标各 3 个环比字段,结构相同
|
||||
|
||||
class GiftCell(CamelModel):
|
||||
value: float
|
||||
compare: str | None = None
|
||||
down: bool | None = None
|
||||
flat: bool | None = None
|
||||
|
||||
class GiftRow(CamelModel):
|
||||
"""赠送卡矩阵一行:合计 / 酒水卡 / 台费卡 / 抵用券。"""
|
||||
label: str # "新增" / "消费" / "余额"
|
||||
total: GiftCell
|
||||
liquor: GiftCell
|
||||
table_fee: GiftCell
|
||||
voucher: GiftCell
|
||||
|
||||
class RechargePanel(CamelModel):
|
||||
actual_income: float
|
||||
first_charge: float
|
||||
renew_charge: float
|
||||
consumed: float
|
||||
card_balance: float
|
||||
gift_rows: list[GiftRow] # 3 行
|
||||
all_card_balance: float
|
||||
# 各项环比字段(同 overview 模式)
|
||||
|
||||
class RevenueStructureRow(CamelModel):
|
||||
id: str
|
||||
name: str
|
||||
desc: str | None = None
|
||||
is_sub: bool = False
|
||||
amount: float
|
||||
discount: float
|
||||
booked: float
|
||||
booked_compare: str | None = None
|
||||
|
||||
class RevenueItem(CamelModel):
|
||||
label: str
|
||||
amount: float
|
||||
|
||||
class ChannelItem(CamelModel):
|
||||
label: str
|
||||
amount: float
|
||||
|
||||
class RevenuePanel(CamelModel):
|
||||
structure_rows: list[RevenueStructureRow]
|
||||
price_items: list[RevenueItem] # 4 项
|
||||
total_occurrence: float
|
||||
discount_items: list[RevenueItem] # 4 项
|
||||
confirmed_total: float
|
||||
channel_items: list[ChannelItem] # 3 项
|
||||
|
||||
class CashflowItem(CamelModel):
|
||||
label: str
|
||||
amount: float
|
||||
|
||||
class CashflowPanel(CamelModel):
|
||||
consume_items: list[CashflowItem] # 3 项
|
||||
recharge_items: list[CashflowItem] # 1 项
|
||||
total: float
|
||||
|
||||
class ExpenseItem(CamelModel):
|
||||
label: str
|
||||
amount: float
|
||||
compare: str | None = None
|
||||
down: bool | None = None
|
||||
flat: bool | None = None
|
||||
|
||||
class ExpensePanel(CamelModel):
|
||||
operation_items: list[ExpenseItem] # 3 项
|
||||
fixed_items: list[ExpenseItem] # 4 项
|
||||
coach_items: list[ExpenseItem] # 4 项
|
||||
platform_items: list[ExpenseItem] # 3 项
|
||||
total: float
|
||||
total_compare: str | None = None
|
||||
total_down: bool | None = None
|
||||
total_flat: bool | None = None
|
||||
|
||||
class CoachAnalysisRow(CamelModel):
|
||||
level: str
|
||||
pay: float
|
||||
share: float
|
||||
hourly: float
|
||||
pay_compare: str | None = None
|
||||
pay_down: bool | None = None
|
||||
share_compare: str | None = None
|
||||
share_down: bool | None = None
|
||||
hourly_compare: str | None = None
|
||||
hourly_flat: bool | None = None
|
||||
|
||||
class CoachAnalysisTable(CamelModel):
|
||||
total_pay: float
|
||||
total_share: float
|
||||
avg_hourly: float
|
||||
total_pay_compare: str | None = None
|
||||
total_pay_down: bool | None = None
|
||||
total_share_compare: str | None = None
|
||||
total_share_down: bool | None = None
|
||||
avg_hourly_compare: str | None = None
|
||||
avg_hourly_flat: bool | None = None
|
||||
rows: list[CoachAnalysisRow] # 4 行:初级/中级/高级/星级
|
||||
|
||||
class CoachAnalysisPanel(CamelModel):
|
||||
basic: CoachAnalysisTable # 基础课/陪打
|
||||
incentive: CoachAnalysisTable # 激励课/超休
|
||||
|
||||
class FinanceBoardResponse(CamelModel):
|
||||
overview: OverviewPanel
|
||||
recharge: RechargePanel | None # area≠all 时为 null
|
||||
revenue: RevenuePanel
|
||||
cashflow: CashflowPanel
|
||||
expense: ExpensePanel
|
||||
coach_analysis: CoachAnalysisPanel
|
||||
```
|
||||
|
||||
### 4.5 CONFIG-1 响应 Schema
|
||||
|
||||
```python
|
||||
class SkillTypeItem(CamelModel):
|
||||
key: str # chinese/snooker/mahjong/karaoke
|
||||
label: str # 中文标签
|
||||
emoji: str # 表情符号
|
||||
cls: str # 前端样式类
|
||||
```
|
||||
|
||||
### 4.6 数据库查询模式
|
||||
|
||||
所有 FDW 查询遵循已有模式:
|
||||
|
||||
```python
|
||||
def get_finance_overview(conn, site_id, start_date, end_date):
|
||||
"""查询经营一览 8 指标(从财务日报聚合)。"""
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
cur.execute("""
|
||||
SELECT SUM(occurrence) AS occurrence,
|
||||
SUM(discount) AS discount,
|
||||
...
|
||||
FROM app.v_dws_finance_daily_summary
|
||||
WHERE stat_date >= %s AND stat_date < %s
|
||||
""", (start_date, end_date))
|
||||
...
|
||||
```
|
||||
|
||||
⚠️ 已有函数复用说明:
|
||||
- `get_salary_calc_batch` 基于已有 `get_salary_calc()` 的 SQL 模式,扩展为 `WHERE assistant_id = ANY(%s)` 批量查询
|
||||
- `get_top_customers_for_coaches` 基于已有 `get_relation_index()` 的 SQL 模式,扩展为按助教维度批量查询 + JOIN v_dim_member
|
||||
- `get_coach_sv_data` 使用 `v_dws_assistant_monthly_summary`(已按助教预聚合),无需从 `v_dws_member_consumption_summary` 手动聚合
|
||||
- BOARD-2 recall/potential/recent/recharge 维度使用 ETL 已计算好的专用指数视图,避免在后端重复计算
|
||||
|
||||
关键 SQL 模式:
|
||||
- **items_sum 口径**:所有金额字段使用 `ledger_amount`(对应 `items_sum`),禁止 `consume_money`
|
||||
- **助教费用拆分**:`base_income`(对应 `assistant_pd_money`)+ `bonus_income`(对应 `assistant_cx_money`),禁止 `service_fee`
|
||||
- **会员信息 DQ-6**:`LEFT JOIN app.v_dim_member ON tenant_member_id = member_id AND scd2_is_current = 1`
|
||||
- **会员卡 DQ-7**:`LEFT JOIN app.v_dim_member_card_account ON tenant_member_id AND scd2_is_current = 1`
|
||||
- **废单排除**:`WHERE is_delete = 0`(RLS 视图使用 `is_delete`)
|
||||
- **正向交易**:`WHERE settle_type IN (1, 3)`
|
||||
- **支付渠道恒等式**:`balance_amount = recharge_card_amount + gift_card_amount`
|
||||
- **现金流互斥**:`platform_settlement_amount` 和 `groupbuy_pay_amount` 互斥
|
||||
|
||||
### 4.7 BOARD-2 分页策略
|
||||
|
||||
```python
|
||||
# SQL 层面使用 LIMIT/OFFSET
|
||||
# 先执行 COUNT(*) 获取 total,再执行分页查询
|
||||
# pageSize 上限 100,默认 20
|
||||
|
||||
def get_customer_board_recall(conn, site_id, project, page, page_size):
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
# 1. 总数
|
||||
cur.execute("SELECT COUNT(*) FROM ... WHERE ...", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# 2. 分页数据
|
||||
offset = (page - 1) * page_size
|
||||
cur.execute("SELECT ... LIMIT %s OFFSET %s",
|
||||
(*params, page_size, offset))
|
||||
items = cur.fetchall()
|
||||
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
```
|
||||
|
||||
### 4.8 BOARD-3 环比字段条件输出
|
||||
|
||||
`compare=0` 时,响应 JSON 中不包含任何环比字段。实现方式:Schema 中环比字段设为 `Optional`,`model_dump(exclude_none=True)` 输出时自动排除 `None` 值。
|
||||
|
||||
```python
|
||||
class OverviewPanel(CamelModel):
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
populate_by_name=True,
|
||||
# exclude_none 在序列化时排除 None 字段
|
||||
)
|
||||
|
||||
occurrence: float
|
||||
occurrence_compare: str | None = None # compare=0 时为 None → 不输出
|
||||
occurrence_down: bool | None = None
|
||||
occurrence_flat: bool | None = None
|
||||
```
|
||||
|
||||
路由层序列化时使用 `response_model_exclude_none=True`:
|
||||
|
||||
```python
|
||||
@router.get("/finance", response_model=FinanceBoardResponse,
|
||||
response_model_exclude_none=True)
|
||||
async def get_finance_board(...): ...
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性(Property)是系统在所有合法执行路径上都应保持为真的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: 日期范围计算正确性
|
||||
|
||||
*对于任意*当前日期和任意 `time` 枚举值(BOARD-1 的 6 种 + BOARD-3 的 8 种),`_calc_date_range(time)` 返回的 `(start_date, end_date)` 应满足:`start_date <= end_date`,且当 `compare=1` 时计算的上期范围 `(prev_start, prev_end)` 应满足 `prev_end <= start_date` 且上期长度等于当期长度。
|
||||
|
||||
**Validates: Requirements 1.3, 3.2, 3.3**
|
||||
|
||||
### Property 2: BOARD-1 排序不变量
|
||||
|
||||
*对于任意* BOARD-1 响应列表和任意 `sort` 参数,列表中相邻两项的排序字段值应满足排序方向约束:`perf_desc` → `items[i].perfHours >= items[i+1].perfHours`,`salary_asc` → `items[i].salary <= items[i+1].salary`,以此类推。
|
||||
|
||||
**Validates: Requirements 1.15, 9.1, 9.2**
|
||||
|
||||
### Property 3: BOARD-2 分页不变量
|
||||
|
||||
*对于任意*相同筛选参数(`dimension`、`project`)的 BOARD-2 请求,(a) `items.length <= pageSize`,(b) 不同 `page` 值返回的 `total` 相同,(c) `page=1` 和 `page=2` 返回的 `items` 无交集(按 `id` 判断)。
|
||||
|
||||
**Validates: Requirements 2.2, 9.3, 9.4**
|
||||
|
||||
### Property 4: 亲密度 emoji 四级映射
|
||||
|
||||
*对于任意* `rs_display` 浮点数值(0-10 范围),亲密度 emoji 映射函数应返回确定的结果:`> 8.5` → 💖,`> 7` → 🧡,`> 5` → 💛,`≤ 5` → 💙,且映射结果与阈值边界一致(如 `rs_display=8.5` 应返回 🧡 而非 💖)。
|
||||
|
||||
**Validates: Requirements 1.6**
|
||||
|
||||
### Property 5: 环比计算公式正确性
|
||||
|
||||
*对于任意*两个非负 `Decimal` 值 `(current, previous)`,`calc_compare(current, previous)` 应满足:(a) `previous > 0` 时 `compare` 字符串等于 `abs((current - previous) / previous * 100)` 格式化为百分比,(b) `current > previous` 时 `is_down=False`,(c) `current < previous` 时 `is_down=True`,(d) `current == previous` 时 `is_flat=True`,(e) `previous=0, current>0` 时返回 `"新增"`,(f) `previous=0, current=0` 时返回 `"持平"`。
|
||||
|
||||
**Validates: Requirements 8.11, 8.12, 8.13, 8.14**
|
||||
|
||||
### Property 6: 环比开关一致性
|
||||
|
||||
*对于任意* BOARD-3 请求参数,当 `compare=0` 时,响应 JSON 序列化后的字符串中不应包含任何以 `Compare`、`Down`、`Flat` 结尾的 key(camelCase 格式)。
|
||||
|
||||
**Validates: Requirements 3.4, 9.8**
|
||||
|
||||
### Property 7: 预收资产区域约束
|
||||
|
||||
*对于任意* BOARD-3 请求,当 `area` 不等于 `all` 时,响应中 `recharge` 字段应为 `null`。
|
||||
|
||||
**Validates: Requirements 3.11, 9.7**
|
||||
|
||||
### Property 8: 经营一览收入确认恒等式
|
||||
|
||||
*对于任意* BOARD-3 响应中的 `overview` 数据,`confirmedRevenue` 应近似等于 `occurrence - abs(discount)`(在 ±0.01 浮点精度范围内)。
|
||||
|
||||
**Validates: Requirements 9.5**
|
||||
|
||||
### Property 9: 经营一览现金结余恒等式
|
||||
|
||||
*对于任意* BOARD-3 响应中的 `overview` 数据,`cashBalance` 应近似等于 `cashIn - cashOut`(在 ±0.01 浮点精度范围内)。
|
||||
|
||||
**Validates: Requirements 9.6**
|
||||
|
||||
### Property 10: 支付渠道恒等式
|
||||
|
||||
*对于任意*涉及支付渠道的数据记录,`balance_amount` 应精确等于 `recharge_card_amount + gift_card_amount`(DWD-DOC 强制规则 3)。
|
||||
|
||||
**Validates: Requirements 8.7, 9.9**
|
||||
|
||||
### Property 11: 参数互斥约束
|
||||
|
||||
*对于任意* BOARD-1 请求,当 `time=last_6m` 且 `sort=sv_desc` 时,API 应返回 HTTP 400 状态码,响应体包含错误信息。
|
||||
|
||||
**Validates: Requirements 1.2, 9.11**
|
||||
|
||||
### Property 12: BOARD-3 幂等性
|
||||
|
||||
*对于任意*相同参数的 BOARD-3 请求(相同 `time`、`area`、`compare`),在底层数据未变更的情况下,两次请求返回的 `overview.occurrence` 和 `overview.cashBalance` 值应完全相同。
|
||||
|
||||
**Validates: Requirements 9.10**
|
||||
|
||||
### Property 13: weeklyVisits 百分比范围
|
||||
|
||||
*对于任意* BOARD-2 `freq60` 维度响应中的 `weeklyVisits` 数组,(a) 数组长度固定为 8,(b) 每个元素的 `pct` 值在 0-100 范围内,(c) 如果存在非零 `val`,则 `max(pct)` 应等于 100。
|
||||
|
||||
**Validates: Requirements 2.20**
|
||||
|
||||
### Property 14: 优雅降级不变量
|
||||
|
||||
*对于任意* BOARD-3 请求,当某个板块(overview/recharge/revenue/cashflow/expense/coachAnalysis)的数据源查询抛出异常时,整体响应仍应返回 HTTP 200,失败板块返回空默认值(空对象或空数组),其他板块数据不受影响。
|
||||
|
||||
**Validates: Requirements 8.9, 8.10**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 6.1 HTTP 错误码
|
||||
|
||||
| 场景 | 状态码 | 响应体 |
|
||||
|------|--------|--------|
|
||||
| 未认证(无 JWT / JWT 过期) | 401 | `{ code: 401, message: "无效的令牌" }` |
|
||||
| 未审核(status ≠ approved) | 403 | `{ code: 403, message: "用户未通过审核" }` |
|
||||
| 权限不足 | 403 | `{ code: 403, message: "权限不足" }` |
|
||||
| 参数互斥(last_6m + sv_desc) | 400 | `{ code: 400, message: "最近6个月不支持客源储值排序" }` |
|
||||
| 无效枚举值 | 422 | FastAPI 自动验证 |
|
||||
| 服务端异常 | 500 | `{ code: 500, message: "Internal Server Error" }` |
|
||||
|
||||
### 6.2 优雅降级策略
|
||||
|
||||
BOARD-3 财务看板采用板块级降级:
|
||||
|
||||
```python
|
||||
async def get_finance_board(time, area, compare, site_id):
|
||||
conn = _get_biz_connection()
|
||||
try:
|
||||
# 每个板块独立 try/except
|
||||
try:
|
||||
overview = _build_overview(conn, site_id, date_range, compare)
|
||||
except Exception:
|
||||
logger.warning("overview 查询失败,降级为空", exc_info=True)
|
||||
overview = _empty_overview()
|
||||
|
||||
try:
|
||||
recharge = _build_recharge(conn, site_id, date_range, compare) if area == "all" else None
|
||||
except Exception:
|
||||
logger.warning("recharge 查询失败,降级为 null", exc_info=True)
|
||||
recharge = None
|
||||
|
||||
# ... 其余板块同理
|
||||
|
||||
return { "overview": overview, "recharge": recharge, ... }
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
BOARD-1 和 BOARD-2 采用整体降级:核心查询失败直接返回 500,扩展字段(如 topCustomers)失败降级为空。
|
||||
|
||||
CONFIG-1 采用空数组降级:ETL cfg 表查询失败返回 `[]`,前端使用硬编码回退。
|
||||
|
||||
### 6.3 FDW 查询异常处理
|
||||
|
||||
所有 `_fdw_context` 内的查询异常由调用方捕获。`fdw_queries.py` 中的函数不做异常吞没,让 service 层决定降级策略。
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 7.1 测试框架
|
||||
|
||||
- **属性测试**:`hypothesis`(Python),已在项目中使用(见 `.hypothesis/` 目录和 `test_site_isolation_properties.py`)
|
||||
- **单元测试**:`pytest`,mock 数据库交互
|
||||
- **集成测试**:`FastAPI TestClient`,mock FDW 连接
|
||||
|
||||
### 7.2 属性测试配置
|
||||
|
||||
每个属性测试使用 `@settings(max_examples=100)` 配置最少 100 次迭代。
|
||||
|
||||
每个测试函数的 docstring 中标注对应的设计属性:
|
||||
|
||||
```python
|
||||
@settings(max_examples=100)
|
||||
@given(...)
|
||||
def test_compare_formula(current, previous):
|
||||
"""Feature: rns1-board-apis, Property 5: 环比计算公式正确性"""
|
||||
...
|
||||
```
|
||||
|
||||
### 7.3 属性测试清单
|
||||
|
||||
| Property | 测试文件 | 测试策略 |
|
||||
|----------|---------|---------|
|
||||
| P1 日期范围 | `test_board_properties.py` | 生成随机日期 + time 枚举,验证 start <= end 和上下期关系 |
|
||||
| P2 排序不变量 | `test_board_properties.py` | 生成随机助教列表,调用排序函数,验证相邻元素顺序 |
|
||||
| P3 分页不变量 | `test_board_properties.py` | 生成随机客户列表 + page/pageSize,验证分页约束 |
|
||||
| P4 emoji 映射 | `test_board_properties.py` | 生成 0-10 范围浮点数,验证映射结果 |
|
||||
| P5 环比公式 | `test_board_properties.py` | 生成非负 Decimal 对,验证公式和边界条件 |
|
||||
| P6 环比开关 | `test_board_properties.py` | 生成 BOARD-3 mock 数据 + compare=0,验证 JSON 无环比 key |
|
||||
| P7 区域约束 | `test_board_properties.py` | 生成 area≠all 的请求,验证 recharge=null |
|
||||
| P8 收入恒等式 | `test_board_properties.py` | 生成 overview 数据,验证 confirmedRevenue ≈ occurrence - |discount| |
|
||||
| P9 现金结余 | `test_board_properties.py` | 生成 overview 数据,验证 cashBalance ≈ cashIn - cashOut |
|
||||
| P10 支付渠道 | `test_board_properties.py` | 生成支付渠道数据,验证恒等式 |
|
||||
| P11 参数互斥 | `test_board_properties.py` | 固定 time=last_6m + sort=sv_desc,验证 400 |
|
||||
| P12 幂等性 | `test_board_properties.py` | 相同参数两次调用,验证结果一致 |
|
||||
| P13 pct 范围 | `test_board_properties.py` | 生成 8 周到店数据,验证 pct 范围和最大值 |
|
||||
| P14 优雅降级 | `test_board_properties.py` | mock 板块查询抛异常,验证整体 200 + 空默认值 |
|
||||
|
||||
### 7.4 单元测试清单
|
||||
|
||||
| 测试目标 | 测试文件 | 覆盖内容 |
|
||||
|----------|---------|---------|
|
||||
| 日期范围边界 | `test_board_unit.py` | 月末、跨年、闰年等边界 |
|
||||
| 环比 "新增"/"持平" | `test_board_unit.py` | previous=0 的两种情况 |
|
||||
| BOARD-1 扁平结构 | `test_board_unit.py` | 验证所有维度字段都存在 |
|
||||
| BOARD-2 各维度排序 | `test_board_unit.py` | 8 个维度各一个排序示例 |
|
||||
| BOARD-3 recharge null | `test_board_unit.py` | area=hallA 时 recharge=null |
|
||||
| CONFIG-1 空数组降级 | `test_board_unit.py` | cfg 表查询失败返回 [] |
|
||||
| 权限检查 | `test_board_unit.py` | 无权限用户访问各看板返回 403 |
|
||||
|
||||
### 7.5 测试文件位置
|
||||
|
||||
```
|
||||
apps/backend/tests/
|
||||
├── test_board_properties.py # 属性测试(14 个 property)
|
||||
└── unit/
|
||||
└── test_board_unit.py # 单元测试
|
||||
```
|
||||
|
||||
335
.kiro/specs/rns1-board-apis/requirements.md
Normal file
335
.kiro/specs/rns1-board-apis/requirements.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# 需求文档 — RNS1.3:三看板接口
|
||||
|
||||
## 简介
|
||||
|
||||
RNS1.3 是 NS1 小程序后端 API 补全项目的第四个子 spec,负责实现 3 个看板接口(BOARD-1 助教看板、BOARD-2 客户看板、BOARD-3 财务看板)、CONFIG-1 技能配置接口、以及前端看板筛选修复。看板是管理层视角的核心功能,其中 BOARD-3 财务看板是全项目最复杂的单个接口(6 个独立板块、200+ 字段、60+ 环比数据点)。
|
||||
|
||||
### 依赖
|
||||
|
||||
- RNS1.0(基础设施与契约重写)必须先完成:全局响应包装中间件(`ResponseWrapperMiddleware`)、camelCase 转换(`CamelModel`)、重写后的 API 契约
|
||||
- RNS1.1 / RNS1.2 可并行开发,无直接依赖
|
||||
- 后端已有 `fdw_queries.py`(FDW 查询集中封装)、`task_manager.py`、`note_service.py`
|
||||
- 前端已有 13 个页面(P5.2 交付),当前使用 mock 数据
|
||||
|
||||
### 来源文档
|
||||
|
||||
- `docs/prd/Neo_Specs/RNS1-split-plan.md` — 拆分计划主文档(RNS1.3 章节,T3-1 ~ T3-7)
|
||||
- `docs/miniprogram-dev/API-contract.md` — API 契约(BOARD-1/2/3、CONFIG-1 完整定义)
|
||||
- `docs/prd/Neo_Specs/NS1-xcx-backend-api.md` — 原始 spec(八¾ 看板筛选交叉矩阵为权威参考)
|
||||
- `docs/prd/Neo_Specs/miniprogram-storyboard-walkthrough-gaps.md` — 管理层视角走查报告(G1~G10 看板相关 Gap)
|
||||
- `docs/prd/Neo_Specs/storyboard-walkthrough-assistant-view.md` — 助教视角走查报告
|
||||
- `docs/reports/DWD-DOC/` — 金额口径与字段语义权威标杆文档
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Backend**:FastAPI 后端应用,位于 `apps/backend/`
|
||||
- **Miniprogram**:微信小程序前端应用,位于 `apps/miniprogram/`
|
||||
- **BOARD_1_API**:助教看板接口 `GET /api/xcx/board/coaches`,返回助教排行列表(4 维度专属字段)
|
||||
- **BOARD_2_API**:客户看板接口 `GET /api/xcx/board/customers`,返回客户排行列表(8 维度专属字段)
|
||||
- **BOARD_3_API**:财务看板接口 `GET /api/xcx/board/finance`,返回 6 个板块的财务数据(overview/recharge/revenue/cashflow/expense/coachAnalysis)
|
||||
- **CONFIG_1_API**:技能类型列表接口 `GET /api/xcx/config/skill-types`,返回助教技能类型配置
|
||||
- **FDW**:PostgreSQL Foreign Data Wrapper,后端通过直连 ETL 库查询 `app.v_*` RLS 视图
|
||||
- **items_sum**:DWD-DOC 强制使用的消费金额口径,= `table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money`
|
||||
- **assistant_pd_money**:助教陪打费用(基础课),DWD-DOC 强制规则 2 要求的拆分字段
|
||||
- **assistant_cx_money**:助教超休费用(激励课),DWD-DOC 强制规则 2 要求的拆分字段
|
||||
- **v_dws_assistant_salary_calc**:ETL RLS 视图,提供助教绩效/档位/收入/工资数据
|
||||
- **v_dws_assistant_monthly_summary**:ETL RLS 视图,提供助教月度汇总(客户数、储值额等)
|
||||
- **v_dim_assistant**:ETL RLS 视图,提供助教基本信息(姓名、技能、入职日期等)
|
||||
- **v_dim_member**:ETL RLS 视图,提供会员基本信息(nickname、mobile),通过 `member_id` 关联,取 `scd2_is_current=1`
|
||||
- **v_dim_member_card_account**:ETL RLS 视图,提供会员卡余额,通过 `tenant_member_id` 关联,取 `scd2_is_current=1`
|
||||
- **v_dws_member_assistant_relation_index**:ETL RLS 视图,提供会员与助教的关系指数
|
||||
- **v_dws_member_consumption_summary**:ETL RLS 视图,提供会员消费汇总
|
||||
- **v_dws_finance_daily_summary**:ETL RLS 视图,提供财务日报汇总数据(经营一览 8 指标 + 现金流入/流出),BOARD-3 overview/cashflow/expense 板块的主数据源
|
||||
- **v_dws_finance_income_structure**:ETL RLS 视图,提供收入结构表 + 正价/优惠/渠道明细,BOARD-3 revenue 板块数据源
|
||||
- **v_dws_finance_recharge_summary**:ETL RLS 视图,提供储值卡 + 赠送卡矩阵数据,BOARD-3 recharge 板块数据源
|
||||
- **v_dws_finance_discount_detail**:ETL RLS 视图,提供优惠明细(大客户优惠/其他优惠拆分),BOARD-3 revenue 板块辅助数据源
|
||||
- **v_dws_finance_expense_summary**:ETL RLS 视图,提供现金流出 4 子分组明细,BOARD-3 expense 板块数据源
|
||||
- **v_dws_platform_settlement**:ETL RLS 视图,提供平台结算数据(汇来米/美团/抖音),BOARD-3 expense.platformItems 数据源
|
||||
- **coach_tasks**:业务库 `biz.coach_tasks` 表,存储助教任务分配与状态
|
||||
- **user_assistant_binding**:认证库 `auth.user_assistant_binding` 表,映射小程序用户与助教身份
|
||||
- **dimType**:BOARD-1 中根据 `sort` 参数映射的维度类型(`perf`/`salary`/`sv`/`task`),决定卡片展示模板
|
||||
- **环比**:月环比,与上一个相同时间周期对比(本月 vs 上月、本周 vs 上周等),返回百分比字符串 + 方向标记
|
||||
- **GiftRow**:赠送卡 3×4 矩阵中的一行(新增/消费/余额),每行含 4 列(合计/酒水卡/台费卡/抵用券)
|
||||
- **RevenueStructureRow**:收入结构表中的一行(9 行含子行),含发生额、优惠、入账金额
|
||||
- **CoachAnalysisTable**:助教分析子表(基础课或激励课),含汇总行 + 按等级分行(初级/中级/高级/星级)
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:实现 BOARD-1 助教看板(T3-1)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在助教看板中按不同维度(定档业绩/工资/客源储值/任务完成)查看助教排行,并支持技能筛选和时间范围切换,以便评估和对比各助教的工作表现。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 1.1 请求参数与筛选
|
||||
|
||||
1. THE BOARD_1_API SHALL 接受 3 个查询参数:`sort`(排序维度,6 种枚举:`perf_desc`/`perf_asc`/`salary_desc`/`salary_asc`/`sv_desc`/`task_desc`)、`skill`(技能筛选,5 种枚举:`all`/`chinese`/`snooker`/`mahjong`/`karaoke`)、`time`(时间范围,6 种枚举:`month`/`quarter`/`last_month`/`last_3m`/`last_quarter`/`last_6m`)
|
||||
2. IF `time=last_6m` 且 `sort=sv_desc`,THEN THE BOARD_1_API SHALL 返回 HTTP 400 `{ code: 400, message: "最近6个月不支持客源储值排序" }`
|
||||
3. THE BOARD_1_API SHALL 根据 `time` 参数计算对应的日期范围(本月=当月1日~末日、上月=上月1日~末日、本季度=季度首日~末日、前3个月=不含本月的前3个月、上季度=上季度、最近6个月=不含本月的前6个月)
|
||||
|
||||
#### 1.2 基础字段(所有维度共享)
|
||||
|
||||
4. THE BOARD_1_API SHALL 为每个助教 item 返回基础字段:`id`(助教 ID)、`name`(助教姓名)、`initial`(姓名首字)、`avatarGradient`(头像渐变色标识)、`level`(等级 key:`star`/`senior`/`middle`/`junior`)、`skills`(技能列表,`Array<{ text: string, cls: string }>`)、`topCustomers`(Top 客户列表,含亲密度 emoji 前缀,如 `['💖 王先生', '💛 李女士']`)
|
||||
5. THE BOARD_1_API SHALL 从 `v_dim_assistant` 获取助教基本信息,从 `v_dws_assistant_salary_calc` 获取等级(`assistant_level_name`)
|
||||
6. THE BOARD_1_API SHALL 从 `v_dws_member_assistant_relation_index` 按亲密度降序取 Top 3 客户,拼接亲密度 emoji(P6 AC3 四级映射:`rs_display > 8.5` → 💖,`> 7` → 🧡,`> 5` → 💛,`≤ 5` → 💙)+ 客户姓名作为 `topCustomers`
|
||||
|
||||
#### 1.3 perf 维度专属字段
|
||||
|
||||
7. WHEN `sort` 为 `perf_desc` 或 `perf_asc` 时,THE BOARD_1_API SHALL 返回 perf 维度字段:`perfHours`(当期定档工时)、`perfHoursBefore`(上期定档工时,可选)、`perfGap`(距升档差距描述,如 `"距升档 13.8h"`,已达标时不返回)、`perfReached`(是否已达标)
|
||||
8. THE BOARD_1_API SHALL 从 `v_dws_assistant_salary_calc` 查询当期和上期的定档工时数据,根据档位阈值计算 `perfGap` 和 `perfReached`
|
||||
|
||||
#### 1.4 salary 维度专属字段
|
||||
|
||||
9. WHEN `sort` 为 `salary_desc` 或 `salary_asc` 时,THE BOARD_1_API SHALL 返回 salary 维度字段:`salary`(预估工资总额,元)、`salaryPerfHours`(定档工时)、`salaryPerfBefore`(上期定档工时,可选)
|
||||
10. THE BOARD_1_API SHALL 使用 `items_sum` 口径计算 `salary` 字段(DWD-DOC 强制规则 1)
|
||||
|
||||
#### 1.5 sv 维度专属字段
|
||||
|
||||
11. WHEN `sort` 为 `sv_desc` 时,THE BOARD_1_API SHALL 返回 sv 维度字段:`svAmount`(客源储值总额,元)、`svCustomerCount`(储值客户数)、`svConsume`(储值消耗额,元)
|
||||
12. THE BOARD_1_API SHALL 从 `v_dws_assistant_monthly_summary` 获取助教月度储值汇总数据(客源储值额、储值客户数、储值消耗额),该视图已按助教维度预聚合
|
||||
|
||||
#### 1.6 task 维度专属字段
|
||||
|
||||
13. WHEN `sort` 为 `task_desc` 时,THE BOARD_1_API SHALL 返回 task 维度字段:`taskRecall`(召回任务完成数)、`taskCallback`(回访任务完成数)
|
||||
14. THE BOARD_1_API SHALL 从 `biz.coach_tasks` 查询指定时间范围内 `status='completed'` 的任务,按 `task_type` 分类统计
|
||||
|
||||
#### 1.7 排序与返回策略
|
||||
|
||||
15. THE BOARD_1_API SHALL 根据 `sort` 参数对结果排序(`perf_desc` 按定档工时降序、`perf_asc` 按定档工时升序、`salary_desc` 按工资降序、`salary_asc` 按工资升序、`sv_desc` 按储值额降序、`task_desc` 按任务完成总数降序)
|
||||
16. THE BOARD_1_API SHALL 始终返回所有维度的字段(扁平结构),前端根据当前 `sort` 选择性渲染对应卡片模板,切换维度时无需重新请求
|
||||
|
||||
### 需求 2:实现 BOARD-2 客户看板(T3-2)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在客户看板中按 8 个维度(最应召回/最大消费潜力/最高余额/最近充值/最近到店/最高消费60天/最频繁60天/最专一60天)查看客户排行,每个维度展示不同的专属字段卡片和关联助教信息,以便从多角度评估客户价值和服务需求。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 2.1 请求参数与分页
|
||||
|
||||
1. THE BOARD_2_API SHALL 接受 4 个查询参数:`dimension`(维度,8 种枚举:`recall`/`potential`/`balance`/`recharge`/`recent`/`spend60`/`freq60`/`loyal`)、`project`(项目筛选,5 种枚举:`all`/`chinese`/`snooker`/`mahjong`/`karaoke`)、`page`(页码,默认 1)、`pageSize`(每页条数,默认 20,上限 100)
|
||||
2. THE BOARD_2_API SHALL 返回分页结构:`items`(客户列表)、`total`(总数)、`page`(当前页)、`pageSize`(每页条数),支持前端 20 条懒加载
|
||||
|
||||
#### 2.2 基础字段(所有维度共享)
|
||||
|
||||
3. THE BOARD_2_API SHALL 为每个客户 item 返回基础字段:`id`(客户 member_id)、`name`(客户姓名)、`initial`(姓名首字)、`avatarCls`(头像样式类)、`assistants`(关联助教列表,`Array<{ name, cls, heartScore, badge?, badgeCls? }>`)
|
||||
4. THE BOARD_2_API SHALL 通过 `member_id` LEFT JOIN `v_dim_member`(取 `scd2_is_current=1`)获取客户姓名(DQ-6)
|
||||
5. THE BOARD_2_API SHALL 从 `biz.coach_tasks` + 亲密度计算获取 `assistants` 列表,按亲密度降序排列,当前跟进助教(`cls='assistant--assignee'`)置顶
|
||||
|
||||
#### 2.3 recall 维度专属字段
|
||||
|
||||
6. WHEN `dimension=recall` 时,THE BOARD_2_API SHALL 返回:`idealDays`(理想到店间隔天数)、`elapsedDays`(已过天数)、`overdueDays`(超期天数 = elapsedDays - idealDays)、`visits30d`(近30天到店次数)、`balance`(余额,格式化字符串)、`recallIndex`(召回指数)
|
||||
7. THE BOARD_2_API SHALL 按 WBI(召回指数)降序排列 recall 维度结果
|
||||
|
||||
#### 2.4 potential 维度专属字段
|
||||
|
||||
8. WHEN `dimension=potential` 时,THE BOARD_2_API SHALL 返回:`potentialTags`(潜力标签列表,`Array<{ text, theme }>`)、`spend30d`(近30天消费)、`avgVisits`(月均到店)、`avgSpend`(次均消费)
|
||||
9. THE BOARD_2_API SHALL 按 SPI(消费潜力指数)降序排列 potential 维度结果
|
||||
|
||||
#### 2.5 balance 维度专属字段
|
||||
|
||||
10. WHEN `dimension=balance` 时,THE BOARD_2_API SHALL 返回:`balance`(当前余额)、`lastVisit`(最近到店描述,如 `"3天前"`)、`monthlyConsume`(月均消耗)、`availableMonths`(可用月数,如 `"约0.8个月"`)
|
||||
11. THE BOARD_2_API SHALL 按 `balance_amount` 降序排列 balance 维度结果
|
||||
|
||||
#### 2.6 recharge 维度专属字段
|
||||
|
||||
12. WHEN `dimension=recharge` 时,THE BOARD_2_API SHALL 返回:`lastRecharge`(最后充值日期)、`rechargeAmount`(充值金额)、`recharges60d`(近60天充值次数)、`currentBalance`(当前余额)
|
||||
13. THE BOARD_2_API SHALL 按 `last_recharge_date` 降序排列 recharge 维度结果
|
||||
|
||||
#### 2.7 recent 维度专属字段
|
||||
|
||||
14. WHEN `dimension=recent` 时,THE BOARD_2_API SHALL 返回:`daysAgo`(距今天数)、`visitFreq`(到店频率,如 `"6.2次/月"`)、`idealDays`(理想间隔)、`visits30d`(近30天到店)、`avgSpend`(次均消费)
|
||||
15. THE BOARD_2_API SHALL 按 `last_visit_date` 降序排列 recent 维度结果
|
||||
|
||||
#### 2.8 spend60 维度专属字段
|
||||
|
||||
16. WHEN `dimension=spend60` 时,THE BOARD_2_API SHALL 返回:`spend60d`(近60天消费总额)、`visits60d`(近60天到店次数)、`highSpendTag`(是否高消费标记)、`avgSpend`(次均消费)
|
||||
17. THE BOARD_2_API SHALL 使用 `items_sum` 口径计算 `spend60d`(DWD-DOC 强制规则 1),按 `items_sum_60d` 降序排列
|
||||
|
||||
#### 2.9 freq60 维度专属字段
|
||||
|
||||
18. WHEN `dimension=freq60` 时,THE BOARD_2_API SHALL 返回:`visits60d`(近60天到店次数)、`avgInterval`(平均到店间隔,如 `"5.0天"`)、`weeklyVisits`(最近8周到店柱状图,`Array<{ val: number, pct: number }>`,固定长度 8)、`spend60d`(近60天消费)
|
||||
19. THE BOARD_2_API SHALL 按 `visit_count_60d` 降序排列 freq60 维度结果
|
||||
20. THE BOARD_2_API SHALL 计算 `weeklyVisits` 中每周的 `pct` 为相对于 8 周最大值的百分比(0-100)
|
||||
|
||||
#### 2.10 loyal 维度专属字段
|
||||
|
||||
21. WHEN `dimension=loyal` 时,THE BOARD_2_API SHALL 返回:`intimacy`(专一度指数)、`topCoachName`(最亲密助教姓名)、`topCoachHeart`(最亲密助教亲密度分数)、`topCoachScore`(最亲密助教关系指数)、`coachName`(主助教姓名)、`coachRatio`(主助教占比,如 `"78%"`)、`coachDetails`(助教明细列表,`Array<{ name, cls, heartScore, badge?, avgDuration, serviceCount, coachSpend, relationIdx }>`)
|
||||
22. THE BOARD_2_API SHALL 按 `max_rs`(最大关系指数)降序排列 loyal 维度结果
|
||||
|
||||
#### 2.11 维度切换策略
|
||||
|
||||
23. THE BOARD_2_API SHALL 按 `dimension` 参数仅返回对应维度的专属字段(减少传输量和查询开销),切换维度时前端重新请求
|
||||
|
||||
### 需求 3:实现 BOARD-3 经营一览 + 预收资产(T3-3)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在财务看板中查看经营一览(8 项核心指标及环比)和预收资产(储值卡 + 赠送卡矩阵),并支持时间范围、区域筛选和环比开关,以便全面掌握门店的经营状况和预收资产变动。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 3.1 请求参数
|
||||
|
||||
1. THE BOARD_3_API SHALL 接受 3 个查询参数:`time`(时间范围,8 种枚举:`month`/`lastMonth`/`week`/`lastWeek`/`quarter3`/`quarter`/`lastQuarter`/`half6`)、`area`(区域筛选,7 种枚举:`all`/`hall`/`hallA`/`hallB`/`hallC`/`mahjong`/`teamBuilding`)、`compare`(环比开关,`0` 或 `1`,默认 `0`)
|
||||
2. THE BOARD_3_API SHALL 根据 `time` 参数计算当期日期范围(`month`=当月1日~末日、`lastMonth`=上月1日~末日、`week`=本周一~本周日、`lastWeek`=上周一~上周日、`quarter3`=前3个月不含本月、`quarter`=本季度首日~末日、`lastQuarter`=上季度、`half6`=最近6个月不含本月)
|
||||
3. WHEN `compare=1` 时,THE BOARD_3_API SHALL 计算上期日期范围(与当期相同长度的前一个周期),分别查询当期和上期数据后计算环比百分比
|
||||
4. WHEN `compare=0` 时,THE BOARD_3_API SHALL 不返回任何环比字段(`xxxCompare`/`isDown`/`isFlat`),减少查询开销
|
||||
|
||||
#### 3.2 经营一览 overview(8 指标 + 8 环比)
|
||||
|
||||
5. THE BOARD_3_API SHALL 返回 `overview` 板块,包含 8 项核心指标:`occurrence`(发生额/正价)、`discount`(总优惠,负值)、`discountRate`(折扣率)、`confirmedRevenue`(成交/确认收入)、`cashIn`(实收/现金流入)、`cashOut`(现金支出)、`cashBalance`(现金结余)、`balanceRate`(结余率)
|
||||
6. WHEN `compare=1` 时,THE BOARD_3_API SHALL 为 overview 每项指标返回 3 个环比字段:`xxxCompare`(环比百分比字符串,如 `"12.5%"` 或 `"持平"`)、`xxxDown`(是否下降,boolean)、`xxxFlat`(是否持平,boolean)
|
||||
7. THE BOARD_3_API SHALL 从 `v_dws_finance_daily_summary` 查询经营一览数据(按日期范围聚合),使用 `items_sum` 口径计算所有金额(DWD-DOC 强制规则 1)
|
||||
|
||||
#### 3.3 预收资产 recharge(储值卡 + 赠送卡矩阵)
|
||||
|
||||
8. WHEN `area=all` 时,THE BOARD_3_API SHALL 返回 `recharge` 板块,包含储值卡 5 项指标:`actualIncome`(充值实收)、`firstCharge`(首充)、`renewCharge`(续费)、`consumed`(消耗)、`cardBalance`(储值卡总余额)
|
||||
9. THE BOARD_3_API SHALL 返回 `recharge.giftRows`(赠送卡 3×4 矩阵),3 行(新增/消费/余额)× 4 列(合计/酒水卡/台费卡/抵用券),每个单元格含值和环比字段,共 24 个数据字段 + 24 个环比字段
|
||||
10. THE BOARD_3_API SHALL 返回 `recharge.allCardBalance`(全类别会员卡余额合计 = 储值卡 + 赠送卡)
|
||||
11. WHEN `area` 不等于 `all` 时,THE BOARD_3_API SHALL 将 `recharge` 板块返回 `null`(储值卡数据不按区域拆分,选中具体区域时无意义)
|
||||
12. THE BOARD_3_API SHALL 从 `v_dws_finance_recharge_summary` 查询预收资产数据
|
||||
|
||||
### 需求 4:实现 BOARD-3 应计收入 + 现金流入(T3-4)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在财务看板中查看应计收入确认(收入结构表含区域子行、正价/优惠/渠道明细)和现金流入(消费收款 + 充值收款),以便分析收入构成和现金来源。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 4.1 应计收入确认 revenue
|
||||
|
||||
1. THE BOARD_3_API SHALL 返回 `revenue.structureRows`(收入结构表),包含 9 行含子行标记:主行(开台与包厢、助教基础课、助教激励课、食品酒水)+ 子行(A区/B区/C区/团建区/麻将区,属于"开台与包厢"的子行,`isSub=true`),每行含 `id`、`name`、`desc`(可选)、`amount`(发生额)、`discount`(优惠金额)、`booked`(入账金额)、`bookedCompare`(入账环比,可选)
|
||||
2. THE BOARD_3_API SHALL 对收入结构表中助教行使用 `assistant_pd_money`(基础课/陪打)和 `assistant_cx_money`(激励课/超休)拆分(DWD-DOC 强制规则 2),禁止使用 `service_fee`
|
||||
3. THE BOARD_3_API SHALL 返回 `revenue.priceItems`(正价明细,4 项)、`revenue.totalOccurrence`(发生额合计)、`revenue.discountItems`(优惠明细,4 项)、`revenue.confirmedTotal`(确认收入合计)、`revenue.channelItems`(渠道明细,3 项:储值卡结算冲销/现金线上支付/团购核销确认收入)
|
||||
4. THE BOARD_3_API SHALL 从 `v_dws_finance_income_structure`(收入结构主表)+ `v_dws_finance_discount_detail`(优惠明细辅助)查询应计收入数据
|
||||
|
||||
#### 4.2 现金流入 cashflow
|
||||
|
||||
5. THE BOARD_3_API SHALL 返回 `cashflow` 板块,包含 `consumeItems`(消费收款,3 项:纸币现金/线上收款/团购平台)、`rechargeItems`(充值收款,1 项:会员充值到账)、`total`(现金流入合计)
|
||||
6. THE BOARD_3_API SHALL 确保 `consumeItems` 中 `platform_settlement_amount` 和 `groupbuy_pay_amount` 互斥(DWD-DOC 强制规则 8:现金流互斥)
|
||||
7. THE BOARD_3_API SHALL 从 `v_dws_finance_daily_summary` 查询现金流入数据(消费收款 + 充值收款字段均在财务日报中)
|
||||
|
||||
### 需求 5:实现 BOARD-3 现金流出 + 助教分析(T3-5)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在财务看板中查看现金流出(经营/固定/助教分成/平台服务费 4 个子分组)和助教分析(基础课 + 激励课各按 4 等级分行),以便分析支出结构和助教成本。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 5.1 现金流出 expense
|
||||
|
||||
1. THE BOARD_3_API SHALL 返回 `expense` 板块,包含 4 个子分组:`operationItems`(经营支出,3 项:食品饮料/耗材/报销)、`fixedItems`(固定支出,4 项:房租/水电/物业/人员工资)、`coachItems`(助教分成,4 项:基础课分成/激励课分成/充值提成/额外奖金)、`platformItems`(平台服务费,3 项:汇来米/美团/抖音)
|
||||
2. THE BOARD_3_API SHALL 返回 `expense.total`(现金流出合计)及其环比字段
|
||||
3. THE BOARD_3_API SHALL 对 `coachItems` 中基础课分成使用 `assistant_pd_money`,激励课分成使用 `assistant_cx_money`(DWD-DOC 强制规则 2)
|
||||
4. THE BOARD_3_API SHALL 从 `v_dws_finance_expense_summary`(支出明细 4 子分组,含助教分成)+ `v_dws_platform_settlement`(平台服务费:汇来米/美团/抖音)查询现金流出数据
|
||||
|
||||
#### 5.2 助教分析 coachAnalysis
|
||||
|
||||
5. THE BOARD_3_API SHALL 返回 `coachAnalysis` 板块,包含 `basic`(基础课/陪打)和 `incentive`(激励课/超休)两个子表,结构完全相同
|
||||
6. THE BOARD_3_API SHALL 为每个子表返回汇总行:`totalPay`(总课时费)、`totalShare`(总分成)、`avgHourly`(平均时薪),各含环比字段
|
||||
7. THE BOARD_3_API SHALL 为每个子表返回 `rows`(按等级分行,4 行:初级/中级/高级/星级),每行含 `level`(等级名)、`pay`(课时费)、`share`(分成)、`hourly`(时薪),各含环比字段和方向标记(`payDown`/`shareDown`/`hourlyFlat`)
|
||||
8. THE BOARD_3_API SHALL 从 `v_dws_assistant_salary_calc` 按 `assistant_level_name` 分组聚合助教分析数据
|
||||
|
||||
### 需求 6:实现 CONFIG-1 技能类型列表(T3-6)
|
||||
|
||||
**用户故事:** 作为管理者,我希望助教看板的技能筛选选项从后端配置表动态获取(而非前端硬编码),以便在新增技能类型时无需发版更新前端。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CONFIG_1_API SHALL 实现 `GET /api/xcx/config/skill-types` 端点,返回技能类型列表,每项含 `key`(枚举值,如 `chinese`/`snooker`/`mahjong`/`karaoke`)、`label`(中文标签)、`emoji`(表情符号)、`cls`(前端样式类)
|
||||
2. THE CONFIG_1_API SHALL 从 ETL cfg 表读取技能类型配置数据
|
||||
3. IF ETL cfg 表查询失败或无数据,THEN THE CONFIG_1_API SHALL 返回空数组,前端 `api.ts` 中的硬编码列表作为 mock 回退
|
||||
4. THE CONFIG_1_API SHALL 对响应设置合理的缓存策略(技能类型变更频率极低)
|
||||
|
||||
### 需求 7:前端看板筛选修复(T3-7)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在看板页面切换筛选条件时能立即看到更新后的数据(而非停留在旧数据),以便实时对比不同维度和时间范围的数据。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 7.1 BOARD-1 筛选修复(F1, F6)
|
||||
|
||||
1. THE Miniprogram SHALL 修复 `board-coach` 页面的 `onSortChange`、`onSkillChange`、`onTimeChange` 事件处理函数,在更新 data 状态后调用 `this.loadData()` 重新请求 API
|
||||
2. THE Miniprogram SHALL 在 `board-coach` 页面实现 `time=last_6m` + `sort=sv_desc` 的互斥约束:选择 `last_6m` 时禁用 `sv_desc` 选项,或选择 `sv_desc` 时禁用 `last_6m` 选项
|
||||
|
||||
#### 7.2 BOARD-2 筛选修复 + 分页补充(F2, F3)
|
||||
|
||||
3. THE Miniprogram SHALL 修复 `board-customer` 页面的 `onDimensionChange`、`onProjectChange` 事件处理函数,在更新 data 状态后调用 `this.loadData()` 重新请求 API
|
||||
4. THE Miniprogram SHALL 为 `board-customer` 页面补充分页参数(`page`/`pageSize`)和"加载更多"懒加载逻辑(`onReachBottom` 触发加载下一页,`pageSize=20`)
|
||||
5. THE Miniprogram SHALL 修改 `services/api.ts` 中 `fetchBoardCustomers` 函数签名,增加 `page` 和 `pageSize` 参数
|
||||
|
||||
#### 7.3 BOARD-3 筛选修复 + 签名扩展(F4, F5)
|
||||
|
||||
6. THE Miniprogram SHALL 修改 `services/api.ts` 中 `fetchBoardFinance` 函数签名,从 `{ date?: string }` 扩展为 `{ time: string, area: string, compare: number }`
|
||||
7. THE Miniprogram SHALL 修复 `board-finance` 页面的 `onTimeChange`、`onAreaChange` 事件处理函数,在更新 data 状态后使用新参数调用 `fetchBoardFinance` 重新请求 API
|
||||
8. THE Miniprogram SHALL 修复 `board-finance` 页面的 `toggleCompare` 函数,切换环比开关后使用 `compare=0` 或 `compare=1` 参数重新请求 API
|
||||
9. WHEN `area` 不等于 `all` 时,THE Miniprogram SHALL 隐藏预收资产板块(`recharge` 为 `null` 时不渲染该 section)
|
||||
|
||||
### 需求 8:全局约束与数据隔离
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望所有看板和配置接口都遵循统一的权限控制、数据隔离和数据质量规则,以确保数据安全和口径一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 8.1 权限与认证
|
||||
|
||||
1. THE Backend SHALL 对所有 RNS1.3 接口(BOARD_1_API、BOARD_2_API、BOARD_3_API、CONFIG_1_API)执行 `require_approved()` 权限检查,确保用户状态为 `approved`
|
||||
2. THE Backend SHALL 对 BOARD_1_API 验证用户具有 `view_board_coach` 权限,对 BOARD_2_API 验证用户具有 `view_board_customer` 权限,对 BOARD_3_API 验证用户具有 `view_board_finance` 权限
|
||||
3. THE Backend SHALL 对所有 RNS1.3 接口通过 `SET LOCAL app.current_site_id` 实现门店级数据隔离(FDW 查询通过 `_fdw_context` 上下文管理器统一执行)
|
||||
|
||||
#### 8.2 DWD-DOC 强制规则
|
||||
|
||||
4. THE Backend SHALL 对所有涉及金额的字段统一使用 `items_sum` 口径(DWD-DOC 强制规则 1),禁止使用 `consume_money`
|
||||
5. THE Backend SHALL 对所有涉及助教费用的字段使用 `assistant_pd_money`(陪打/基础课)+ `assistant_cx_money`(超休/激励课)拆分(DWD-DOC 强制规则 2),禁止使用 `service_fee`
|
||||
6. THE Backend SHALL 对所有涉及会员信息的查询通过 `member_id` LEFT JOIN `v_dim_member`(取 `scd2_is_current=1`)获取姓名和手机号(DWD-DOC 强制规则 DQ-6),禁止直接使用 `settlement_head.member_phone` 或 `member_name`
|
||||
7. THE Backend SHALL 确保支付渠道恒等式 `balance_amount = recharge_card_amount + gift_card_amount`(DWD-DOC 强制规则 3),三者不可重复计算
|
||||
8. THE Backend SHALL 确保现金流互斥:`platform_settlement_amount` 和 `groupbuy_pay_amount` 互斥(DWD-DOC 强制规则 8)
|
||||
|
||||
#### 8.3 优雅降级
|
||||
|
||||
9. IF 某个看板板块的数据源查询失败,THEN THE Backend SHALL 对该板块返回空默认值(空对象或空数组),不影响其他板块和整体响应
|
||||
10. THE Backend SHALL 对所有 FDW 查询异常进行捕获和日志记录,返回降级响应而非 HTTP 500
|
||||
|
||||
#### 8.4 环比计算通用规则
|
||||
|
||||
11. THE Backend SHALL 对所有环比计算采用统一公式:`compareValue = (当期值 - 上期值) / 上期值 × 100%`,格式化为百分比字符串(如 `"12.5%"`)
|
||||
12. WHEN 上期值为 0 且当期值不为 0 时,THE Backend SHALL 返回 `xxxCompare: "新增"`,`xxxDown: false`,`xxxFlat: false`
|
||||
13. WHEN 上期值和当期值均为 0 时,THE Backend SHALL 返回 `xxxCompare: "持平"`,`xxxDown: false`,`xxxFlat: true`
|
||||
14. THE Backend SHALL 根据环比值设置方向标记:正值 → `isDown=false`,负值 → `isDown=true`,零值 → `isFlat=true`
|
||||
|
||||
### 需求 9:正确性属性(Property-Based Testing)
|
||||
|
||||
**用户故事:** 作为开发者,我希望通过属性测试验证看板接口的数据一致性和业务规则正确性,以便在开发阶段发现口径错误和数据异常。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 9.1 BOARD-1 排序不变量
|
||||
|
||||
1. FOR ALL BOARD_1_API 响应(`sort=perf_desc`),列表 SHALL 按 `perfHours` 降序排列(前一项的 `perfHours` ≥ 后一项的 `perfHours`)
|
||||
2. FOR ALL BOARD_1_API 响应(`sort=salary_asc`),列表 SHALL 按 `salary` 升序排列(前一项的 `salary` ≤ 后一项的 `salary`)
|
||||
|
||||
#### 9.2 BOARD-2 分页不变量
|
||||
|
||||
3. FOR ALL 相同参数的 BOARD_2_API 请求(相同 `dimension`、`project`),`page=1` 返回的 `total` SHALL 等于 `page=2` 返回的 `total`(总数在分页间保持一致)
|
||||
4. FOR ALL BOARD_2_API 响应,`items.length` SHALL 小于等于 `pageSize`
|
||||
|
||||
#### 9.3 BOARD-3 经营一览恒等式
|
||||
|
||||
5. FOR ALL BOARD_3_API 响应中的 `overview`,`confirmedRevenue` SHALL 近似等于 `occurrence` 减去 `discount` 的绝对值(在浮点精度范围内),验证收入确认公式
|
||||
6. FOR ALL BOARD_3_API 响应中的 `overview`,`cashBalance` SHALL 近似等于 `cashIn` 减去 `cashOut`(在浮点精度范围内),验证现金结余公式
|
||||
|
||||
#### 9.4 BOARD-3 预收资产区域约束
|
||||
|
||||
7. FOR ALL BOARD_3_API 响应(`area` 不等于 `all`),`recharge` SHALL 为 `null`,验证预收资产区域隐藏规则
|
||||
|
||||
#### 9.5 BOARD-3 环比开关一致性
|
||||
|
||||
8. FOR ALL BOARD_3_API 响应(`compare=0`),响应 JSON 中 SHALL 不包含任何以 `Compare`、`Down`、`Flat` 结尾的字段,验证环比开关关闭时不返回环比数据
|
||||
|
||||
#### 9.6 BOARD-3 支付渠道恒等式
|
||||
|
||||
9. FOR ALL BOARD_3_API 响应中涉及支付渠道的数据,`balance_amount` SHALL 等于 `recharge_card_amount + gift_card_amount`(DWD-DOC 强制规则 3),验证支付渠道恒等式
|
||||
|
||||
#### 9.7 幂等性
|
||||
|
||||
10. FOR ALL 相同参数的 BOARD_3_API 请求(相同 `time`、`area`、`compare`),在数据未变更的情况下,两次请求 SHALL 返回相同的 `overview.occurrence` 和 `overview.cashBalance` 值
|
||||
|
||||
#### 9.8 BOARD-1 交叉约束
|
||||
|
||||
11. FOR ALL BOARD_1_API 请求(`time=last_6m` 且 `sort=sv_desc`),响应 SHALL 为 HTTP 400,验证不兼容参数组合的拒绝规则
|
||||
319
.kiro/specs/rns1-board-apis/tasks.md
Normal file
319
.kiro/specs/rns1-board-apis/tasks.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# Implementation Plan: RNS1.3 三看板接口
|
||||
|
||||
## Overview
|
||||
|
||||
基于 design.md 的模块结构,增量扩展后端路由、服务层和 FDW 查询层,新增 3 个看板端点 + 1 个配置端点,并完成前端筛选修复。BOARD-3 财务看板是最复杂的单个接口(6 板块、200+ 字段、60+ 环比数据点),采用板块级独立查询和独立降级策略。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. 通用工具函数(日期范围 + 环比计算)
|
||||
- [x] 1.1 在 `apps/backend/app/services/board_service.py` 中实现 `_calc_date_range(time_enum, ref_date=None)` 工具函数
|
||||
- 支持 BOARD-1 的 6 种时间枚举(`month`/`quarter`/`last_month`/`last_3m`/`last_quarter`/`last_6m`)和 BOARD-3 的 8 种时间枚举(`month`/`lastMonth`/`week`/`lastWeek`/`quarter3`/`quarter`/`lastQuarter`/`half6`)
|
||||
- 返回 `(start_date, end_date)` 元组,`date` 类型
|
||||
- _Requirements: 1.3, 3.2_
|
||||
- [x] 1.2 在 `board_service.py` 中实现 `_calc_prev_range(start_date, end_date)` 计算上期日期范围
|
||||
- 上期长度等于当期长度,`prev_end <= start_date`
|
||||
- _Requirements: 3.3_
|
||||
- [x] 1.3 在 `board_service.py` 中实现 `calc_compare(current: Decimal, previous: Decimal) -> dict` 环比计算工具
|
||||
- 返回 `{ compare: str, is_down: bool, is_flat: bool }`
|
||||
- 边界:`previous=0, current≠0` → `"新增"`;`previous=0, current=0` → `"持平"`
|
||||
- _Requirements: 8.11, 8.12, 8.13, 8.14_
|
||||
|
||||
- [x] 2. Pydantic Schema 定义
|
||||
- [x] 2.1 新建 `apps/backend/app/schemas/xcx_board.py`,定义请求参数枚举
|
||||
- `CoachSortEnum`(6 值)、`SkillFilterEnum`(5 值)、`BoardTimeEnum`(6 值)
|
||||
- `CustomerDimensionEnum`(8 值)、`ProjectFilterEnum`(5 值)
|
||||
- `FinanceTimeEnum`(8 值)、`AreaFilterEnum`(7 值)
|
||||
- _Requirements: 1.1, 2.1, 3.1_
|
||||
- [x] 2.2 在 `xcx_board.py` 中定义 BOARD-1 响应 Schema
|
||||
- `CoachSkillItem`、`CoachBoardItem`(扁平结构,含 perf/salary/sv/task 全部维度字段)、`CoachBoardResponse`(items + dimType)
|
||||
- _Requirements: 1.4~1.14, 1.16_
|
||||
- [x] 2.3 在 `xcx_board.py` 中定义 BOARD-2 响应 Schema
|
||||
- `CustomerAssistant`、`CustomerBoardItemBase`(基础字段)
|
||||
- 8 个维度专属 Schema:`RecallItem`、`PotentialItem`、`BalanceItem`、`RechargeItem`、`RecentItem`、`Spend60Item`、`Freq60Item`、`LoyalItem`
|
||||
- `WeeklyVisit`(val + pct)、`PotentialTag`、`CoachDetail`
|
||||
- `CustomerBoardResponse`(items + total + page + pageSize)
|
||||
- _Requirements: 2.3~2.22_
|
||||
- [x] 2.4 在 `xcx_board.py` 中定义 BOARD-3 响应 Schema
|
||||
- `OverviewPanel`(8 指标 + 各 3 个环比字段,Optional)
|
||||
- `GiftCell`、`GiftRow`、`RechargePanel`(储值卡 5 指标 + 赠送卡 3×4 矩阵 + allCardBalance)
|
||||
- `RevenueStructureRow`、`RevenueItem`、`ChannelItem`、`RevenuePanel`
|
||||
- `CashflowItem`、`CashflowPanel`
|
||||
- `ExpenseItem`、`ExpensePanel`(4 子分组 + total)
|
||||
- `CoachAnalysisRow`、`CoachAnalysisTable`、`CoachAnalysisPanel`(basic + incentive)
|
||||
- `FinanceBoardResponse`(overview + recharge|null + revenue + cashflow + expense + coachAnalysis)
|
||||
- _Requirements: 3.5~3.12, 4.1~4.7, 5.1~5.8_
|
||||
- [x] 2.5 新建 `apps/backend/app/schemas/xcx_config.py`,定义 `SkillTypeItem`(key/label/emoji/cls)
|
||||
- _Requirements: 6.1_
|
||||
|
||||
- [x] 3. FDW 查询层扩展 — BOARD-1
|
||||
- [x] 3.1 在 `apps/backend/app/services/fdw_queries.py` 中实现 `get_all_assistants(conn, site_id, skill_filter)`
|
||||
- 数据源:`app.v_dim_assistant`,按 `skill` 筛选
|
||||
- _Requirements: 1.5_
|
||||
- [x] 3.2 实现 `get_salary_calc_batch(conn, site_id, assistant_ids, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_assistant_salary_calc`,批量查询当期和上期绩效
|
||||
- ⚠️ 基于已有 `get_salary_calc()` 的 SQL 模式扩展,复用列名映射(salary_month/effective_hours/gross_salary/base_income/bonus_income)
|
||||
- ⚠️ DWD-DOC 规则 1:收入使用 items_sum 口径
|
||||
- ⚠️ DWD-DOC 规则 2:费用使用 assistant_pd_money + assistant_cx_money
|
||||
- _Requirements: 1.8, 1.10_
|
||||
- [x] 3.3 实现 `get_top_customers_for_coaches(conn, site_id, assistant_ids)`
|
||||
- 数据源:`app.v_dws_member_assistant_relation_index` + `app.v_dim_member`
|
||||
- ⚠️ 基于已有 `get_relation_index()` 的 SQL 模式扩展为按助教维度批量查询
|
||||
- 按亲密度降序取 Top 3,拼接 P6 AC3 四级 emoji(`> 8.5` → 💖,`> 7` → 🧡,`> 5` → 💛,`≤ 5` → 💙)
|
||||
- ⚠️ DQ-6:客户姓名通过 member_id JOIN v_dim_member,取 scd2_is_current=1
|
||||
- ⚠️ 注意:已有 `get_coach_top_customers()` 按服务次数排序(来自 v_dwd_assistant_service_log),本函数按亲密度排序(来自 v_dws_member_assistant_relation_index),语义不同,不可复用
|
||||
- _Requirements: 1.6_
|
||||
- [x] 3.4 实现 `get_coach_sv_data(conn, site_id, assistant_ids, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_assistant_monthly_summary`(已按助教维度预聚合,含客源储值额/储值客户数/储值消耗额)
|
||||
- ⚠️ 不使用 `v_dws_member_consumption_summary`(那是按客户维度的汇总表,需要额外关联助教再聚合,效率低且语义不匹配)
|
||||
- _Requirements: 1.12_
|
||||
|
||||
- [x] 4. FDW 查询层扩展 — BOARD-2(8 维度)
|
||||
- [x] 4.1 实现 `get_customer_board_recall(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dws_member_winback_index` + `app.v_dim_member`(ETL 已计算 WBI 召回指数,含 ideal_days/elapsed_days/overdue_days/visits_30d/wbi_score)
|
||||
- 按 WBI(wbi_score)降序,LIMIT/OFFSET 分页
|
||||
- ⚠️ DQ-6:客户姓名通过 member_id JOIN v_dim_member
|
||||
- ⚠️ 余额通过 JOIN v_dim_member_card_account 获取
|
||||
- _Requirements: 2.6, 2.7_
|
||||
- [x] 4.2 实现 `get_customer_board_potential(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dws_member_spending_power_index`(ETL 已计算 SPI 消费潜力指数,含 potential_tags/spend_30d/avg_visits/avg_spend/spi_score)
|
||||
- 按 SPI(spi_score)降序
|
||||
- _Requirements: 2.8, 2.9_
|
||||
- [x] 4.3 实现 `get_customer_board_balance(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dim_member_card_account` + `app.v_dim_member`
|
||||
- 按 balance_amount 降序
|
||||
- ⚠️ DQ-7:余额通过 tenant_member_id JOIN,取 scd2_is_current=1
|
||||
- _Requirements: 2.10, 2.11_
|
||||
- [x] 4.4 实现 `get_customer_board_recharge(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dwd_recharge_order` + `app.v_dim_member_card_account`(充值记录 + 当前余额)
|
||||
- 按 last_recharge_date 降序
|
||||
- _Requirements: 2.12, 2.13_
|
||||
- [x] 4.5 实现 `get_customer_board_recent(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dws_member_visit_detail` + `app.v_dim_member`(ETL 已计算到店明细,含 last_visit_date/visit_freq/ideal_days)
|
||||
- 按 last_visit_date 降序
|
||||
- _Requirements: 2.14, 2.15_
|
||||
- [x] 4.6 实现 `get_customer_board_spend60(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dws_member_consumption_summary`(items_sum_60d 已在汇总表中预计算)
|
||||
- ⚠️ DWD-DOC 规则 1:使用 items_sum 口径计算 spend60d
|
||||
- 按 items_sum_60d 降序
|
||||
- _Requirements: 2.16, 2.17_
|
||||
- [x] 4.7 实现 `get_customer_board_freq60(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dws_member_consumption_summary`(visit_count_60d 已在汇总表中预计算)
|
||||
- 含 weeklyVisits 8 周柱状图计算(pct 相对最大值百分比 0-100)
|
||||
- ⚠️ weeklyVisits 需从 `app.v_dwd_assistant_service_log` 按周分组统计(汇总表无周粒度数据)
|
||||
- 按 visit_count_60d 降序
|
||||
- _Requirements: 2.18, 2.19, 2.20_
|
||||
- [x] 4.8 实现 `get_customer_board_loyal(conn, site_id, project, page, page_size)`
|
||||
- 数据源:`app.v_dws_member_assistant_relation_index`
|
||||
- 按 max_rs 降序
|
||||
- _Requirements: 2.21, 2.22_
|
||||
- [x] 4.9 实现 `get_customer_assistants(conn, site_id, member_ids)` 批量查询客户关联助教列表
|
||||
- 含亲密度计算,当前跟进助教置顶
|
||||
- _Requirements: 2.5_
|
||||
|
||||
- [x] 5. FDW 查询层扩展 — BOARD-3(6 板块)
|
||||
- [x] 5.1 实现 `get_finance_overview(conn, site_id, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_finance_daily_summary`(按日期范围 SUM 聚合),返回 8 项核心指标
|
||||
- ⚠️ DWD-DOC 规则 1:使用 items_sum 口径
|
||||
- ⚠️ 注意:ETL 中不存在名为 `v_dws_finance_overview` 的视图,实际视图为 `v_dws_finance_daily_summary`
|
||||
- _Requirements: 3.5, 3.7_
|
||||
- [x] 5.2 实现 `get_finance_recharge(conn, site_id, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_finance_recharge_summary`,返回储值卡 5 指标 + 赠送卡 3×4 矩阵
|
||||
- ⚠️ 注意:ETL 中不存在名为 `v_dws_finance_recharge` 的视图,实际视图为 `v_dws_finance_recharge_summary`
|
||||
- _Requirements: 3.8, 3.9, 3.10, 3.12_
|
||||
- [x] 5.3 实现 `get_finance_revenue(conn, site_id, start_date, end_date, area)`
|
||||
- 数据源:`app.v_dws_finance_income_structure`(收入结构主表)+ `app.v_dws_finance_discount_detail`(优惠明细辅助)
|
||||
- ⚠️ DWD-DOC 规则 2:助教行使用 assistant_pd_money(基础课)+ assistant_cx_money(激励课)
|
||||
- ⚠️ 注意:ETL 中不存在名为 `v_dws_finance_revenue` 的视图,需组合两个实际视图
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4_
|
||||
- [x] 5.4 实现 `get_finance_cashflow(conn, site_id, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_finance_daily_summary`(消费收款 + 充值收款字段均在财务日报中)
|
||||
- ⚠️ DWD-DOC 规则 7:platform_settlement_amount 和 groupbuy_pay_amount 互斥
|
||||
- ⚠️ 注意:ETL 中不存在名为 `v_dws_finance_cashflow` 的独立视图,复用财务日报
|
||||
- _Requirements: 4.5, 4.6, 4.7_
|
||||
- [x] 5.5 实现 `get_finance_expense(conn, site_id, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_finance_expense_summary`(支出明细 4 子分组)+ `app.v_dws_platform_settlement`(平台服务费:汇来米/美团/抖音)
|
||||
- ⚠️ DWD-DOC 规则 2:coachItems 中基础课使用 assistant_pd_money,激励课使用 assistant_cx_money
|
||||
- ⚠️ 注意:ETL 中不存在名为 `v_dws_finance_expense` 的视图,需组合两个实际视图
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4_
|
||||
- [x] 5.6 实现 `get_finance_coach_analysis(conn, site_id, start_date, end_date)`
|
||||
- 数据源:`app.v_dws_assistant_salary_calc`,按 assistant_level_name 分组聚合
|
||||
- 返回 basic(基础课/陪打)+ incentive(激励课/超休)两个子表
|
||||
- _Requirements: 5.5, 5.6, 5.7, 5.8_
|
||||
- [x] 5.7 实现 `get_skill_types(conn, site_id)` 查询技能类型配置
|
||||
- 数据源:ETL cfg 表
|
||||
- _Requirements: 6.2_
|
||||
|
||||
- [x] 6. Checkpoint — FDW 查询层验证
|
||||
- All FDW query functions compile and type-check correctly (getDiagnostics: 0 errors).
|
||||
|
||||
- [x] 7. 服务层 — BOARD-1 助教看板
|
||||
- [x] 7.1 在 `board_service.py` 中实现 `get_coach_board(sort, skill, time, site_id) -> dict`
|
||||
- 参数互斥校验:`time=last_6m` + `sort=sv_desc` → HTTP 400
|
||||
- 日期范围计算 → 查询助教列表 → 批量查询绩效/Top 客户/储值/任务 → 排序 → 组装扁平响应
|
||||
- topCustomers 查询失败降级为空列表
|
||||
- _Requirements: 1.1~1.16_
|
||||
- [x] 7.2 在 `board_service.py` 中实现 `_query_coach_tasks(site_id, assistant_ids, start_date, end_date)` 查询任务完成数
|
||||
- 数据源:`biz.coach_tasks`,按 task_type 分类统计 recall/callback
|
||||
- _Requirements: 1.13, 1.14_
|
||||
|
||||
- [x] 8. 服务层 — BOARD-2 客户看板
|
||||
- [x] 8.1 在 `board_service.py` 中实现 `get_customer_board(dimension, project, page, page_size, site_id) -> dict`
|
||||
- 按 dimension 参数路由到对应 FDW 查询函数
|
||||
- 批量查询客户关联助教列表
|
||||
- 组装分页响应(items + total + page + pageSize)
|
||||
- _Requirements: 2.1~2.23_
|
||||
|
||||
- [x] 9. 服务层 — BOARD-3 财务看板
|
||||
- [x] 9.1 在 `board_service.py` 中实现 `get_finance_board(time, area, compare, site_id) -> dict`
|
||||
- 日期范围计算 → 6 板块独立查询、独立 try/except 降级
|
||||
- `area≠all` 时 recharge 返回 null
|
||||
- `compare=1` 时计算上期范围并调用 calc_compare
|
||||
- `compare=0` 时环比字段为 None(序列化时排除)
|
||||
- _Requirements: 3.1~3.12, 4.1~4.7, 5.1~5.8, 8.9, 8.10_
|
||||
- [x] 9.2 实现 `_build_overview(conn, site_id, date_range, prev_range, compare)` 经营一览板块构建
|
||||
- _Requirements: 3.5, 3.6, 3.7_
|
||||
- [x] 9.3 实现 `_build_recharge(conn, site_id, date_range, prev_range, compare)` 预收资产板块构建
|
||||
- _Requirements: 3.8~3.12_
|
||||
- [x] 9.4 实现 `_build_revenue(conn, site_id, date_range, area, prev_range, compare)` 应计收入板块构建
|
||||
- _Requirements: 4.1~4.4_
|
||||
- [x] 9.5 实现 `_build_cashflow(conn, site_id, date_range, prev_range, compare)` 现金流入板块构建
|
||||
- _Requirements: 4.5~4.7_
|
||||
- [x] 9.6 实现 `_build_expense(conn, site_id, date_range, prev_range, compare)` 现金流出板块构建
|
||||
- _Requirements: 5.1~5.4_
|
||||
- [x] 9.7 实现 `_build_coach_analysis(conn, site_id, date_range, prev_range, compare)` 助教分析板块构建
|
||||
- _Requirements: 5.5~5.8_
|
||||
- [x] 9.8 实现各板块的 `_empty_*()` 空默认值工厂函数(优雅降级用)
|
||||
- _Requirements: 8.9, 8.10_
|
||||
|
||||
- [x] 10. 路由层 + 路由注册
|
||||
- [x] 10.1 新建 `apps/backend/app/routers/xcx_board.py`,实现 3 个看板端点
|
||||
- `GET /api/xcx/board/coaches` — require_permission("view_board_coach")
|
||||
- `GET /api/xcx/board/customers` — require_permission("view_board_customer")
|
||||
- `GET /api/xcx/board/finance` — require_permission("view_board_finance"),`response_model_exclude_none=True`
|
||||
- _Requirements: 8.1, 8.2, 8.3_
|
||||
- [x] 10.2 新建 `apps/backend/app/routers/xcx_config.py`,实现 CONFIG-1 端点
|
||||
- `GET /api/xcx/config/skill-types` — require_approved()
|
||||
- 查询失败降级返回空数组
|
||||
- _Requirements: 6.1~6.4_
|
||||
- [x] 10.3 在 `apps/backend/app/main.py` 中注册 `xcx_board` 和 `xcx_config` 路由
|
||||
- _Requirements: 8.1_
|
||||
|
||||
- [x] 11. Checkpoint — 后端接口验证
|
||||
- All backend endpoints compile and type-check correctly (getDiagnostics: 0 errors on all router files and main.py).
|
||||
|
||||
- [x] 12. 前端筛选修复 — BOARD-1(T3-7 F1, F6)
|
||||
- [x] 12.1 修复 `apps/miniprogram/miniprogram/pages/board-coach/` 页面的 `onSortChange`、`onSkillChange`、`onTimeChange` 事件处理函数
|
||||
- 更新 data 状态后调用 `this.loadData()` 重新请求 API
|
||||
- _Requirements: 7.1_
|
||||
- [x] 12.2 实现 `time=last_6m` + `sort=sv_desc` 互斥约束
|
||||
- 选择 `last_6m` 时禁用 `sv_desc` 选项,或选择 `sv_desc` 时禁用 `last_6m` 选项
|
||||
- _Requirements: 7.2_
|
||||
|
||||
- [x] 13. 前端筛选修复 — BOARD-2(T3-7 F2, F3)
|
||||
- [x] 13.1 修复 `apps/miniprogram/miniprogram/pages/board-customer/` 页面的 `onDimensionChange`、`onProjectChange` 事件处理函数
|
||||
- 更新 data 状态后调用 `this.loadData()` 重新请求 API
|
||||
- _Requirements: 7.3_
|
||||
- [x] 13.2 补充分页参数和懒加载逻辑
|
||||
- `onReachBottom` 触发加载下一页,`pageSize=20`
|
||||
- _Requirements: 7.4_
|
||||
- [x] 13.3 修改 `services/api.ts` 中 `fetchBoardCustomers` 函数签名,增加 `page` 和 `pageSize` 参数
|
||||
- _Requirements: 7.5_
|
||||
|
||||
- [x] 14. 前端筛选修复 — BOARD-3(T3-7 F4, F5)
|
||||
- [x] 14.1 修改 `services/api.ts` 中 `fetchBoardFinance` 函数签名
|
||||
- 从 `{ date?: string }` 扩展为 `{ time: string, area: string, compare: number }`
|
||||
- _Requirements: 7.6_
|
||||
- [x] 14.2 修复 `apps/miniprogram/miniprogram/pages/board-finance/` 页面的 `onTimeChange`、`onAreaChange` 事件处理函数
|
||||
- 更新 data 状态后使用新参数调用 `fetchBoardFinance`
|
||||
- _Requirements: 7.7_
|
||||
- [x] 14.3 修复 `toggleCompare` 函数,切换环比开关后使用 `compare=0/1` 参数重新请求
|
||||
- _Requirements: 7.8_
|
||||
- [x] 14.4 `area≠all` 时隐藏预收资产板块(`recharge` 为 null 时不渲染该 section)
|
||||
- _Requirements: 7.9_
|
||||
|
||||
- [x] 15. Checkpoint — 前端筛选修复验证
|
||||
- All frontend filter fixes implemented: event handlers call loadData(), API signatures extended, pagination added to BOARD-2, mutual exclusion constraint for BOARD-1.
|
||||
|
||||
- [x] 16. 属性测试(Property-Based Testing)
|
||||
- [x] 16.1 新建 `tests/test_board_properties.py`,实现 Property 1: 日期范围计算正确性
|
||||
- 生成器:`st.dates()` + `st.sampled_from(BoardTimeEnum/FinanceTimeEnum)`
|
||||
- 验证:`start_date <= end_date`,上期 `prev_end <= start_date`,上期长度 = 当期长度
|
||||
- **Validates: Requirements 1.3, 3.2, 3.3 — Design Property 1**
|
||||
- [x] 16.2 实现 Property 2: BOARD-1 排序不变量
|
||||
- 生成器:随机助教列表 + `st.sampled_from(CoachSortEnum)`
|
||||
- 验证:相邻元素排序字段满足方向约束
|
||||
- **Validates: Requirements 1.15, 9.1, 9.2 — Design Property 2**
|
||||
- [x] 16.3 实现 Property 3: BOARD-2 分页不变量
|
||||
- 生成器:随机客户列表 + page/pageSize
|
||||
- 验证:`items.length <= pageSize`,total 跨页一致,无交集
|
||||
- **Validates: Requirements 2.2, 9.3, 9.4 — Design Property 3**
|
||||
- [x] 16.4 实现 Property 4: 亲密度 emoji 四级映射
|
||||
- 生成器:`st.floats(min_value=0, max_value=10)`
|
||||
- 验证:`> 8.5` → 💖,`> 7` → 🧡,`> 5` → 💛,`≤ 5` → 💙;边界 `8.5` → 🧡
|
||||
- **Validates: Requirements 1.6 — Design Property 4**
|
||||
- [x] 16.5 实现 Property 5: 环比计算公式正确性
|
||||
- 生成器:`st.decimals(min_value=0, max_value=1e8)` × 2
|
||||
- 验证:公式正确、方向标记正确、"新增"/"持平" 边界
|
||||
- **Validates: Requirements 8.11~8.14 — Design Property 5**
|
||||
- [x] 16.6 实现 Property 6: 环比开关一致性
|
||||
- 生成 BOARD-3 mock 数据 + compare=0,序列化后验证 JSON 无 Compare/Down/Flat key
|
||||
- **Validates: Requirements 3.4, 9.8 — Design Property 6**
|
||||
- [x] 16.7 实现 Property 7: 预收资产区域约束
|
||||
- 生成 area≠all 的请求,验证 recharge=null
|
||||
- **Validates: Requirements 3.11, 9.7 — Design Property 7**
|
||||
- [x] 16.8 实现 Property 8+9: 经营一览恒等式
|
||||
- 验证 `confirmedRevenue ≈ occurrence - abs(discount)`(±0.01)
|
||||
- 验证 `cashBalance ≈ cashIn - cashOut`(±0.01)
|
||||
- **Validates: Requirements 9.5, 9.6 — Design Property 8, 9**
|
||||
- [x] 16.9 实现 Property 10: 支付渠道恒等式
|
||||
- 验证 `balance_amount = recharge_card_amount + gift_card_amount`
|
||||
- **Validates: Requirements 8.7, 9.9 — Design Property 10**
|
||||
- [x] 16.10 实现 Property 11: 参数互斥约束
|
||||
- 固定 `time=last_6m` + `sort=sv_desc`,验证 HTTP 400
|
||||
- **Validates: Requirements 1.2, 9.11 — Design Property 11**
|
||||
- [x] 16.11 实现 Property 13: weeklyVisits 百分比范围
|
||||
- 生成 8 周到店数据,验证长度=8、pct 0-100、max(pct)=100
|
||||
- **Validates: Requirements 2.20 — Design Property 13**
|
||||
- [x] 16.12 实现 Property 14: 优雅降级不变量
|
||||
- mock 板块查询抛异常,验证整体 HTTP 200 + 失败板块空默认值
|
||||
- **Validates: Requirements 8.9, 8.10 — Design Property 14**
|
||||
|
||||
- [x] 17. Final Checkpoint — 全量验证
|
||||
- Run all property tests: `cd C:\NeoZQYY && pytest tests/test_board_properties.py -v`
|
||||
- Ensure all 12 property tests pass. Ask the user if questions arise.
|
||||
|
||||
- [x] 18. 前端到数据库全链路测试
|
||||
- [x] 18.1 启动后端服务,使用测试库(`test_zqyy_app`)验证 BOARD-1、BOARD-2、BOARD-3、CONFIG-1 四个端点的完整请求-响应链路
|
||||
- 使用真实 FDW 连接(`test_etl_feiqiu`)验证 SQL 查询正确性
|
||||
- 验证 JSON 响应结构与 Schema 定义一致(camelCase 序列化)
|
||||
- 验证权限校验(`require_permission()` / `require_approved()`)在真实请求中生效
|
||||
- 验证 `SET LOCAL app.current_site_id` 数据隔离在真实请求中生效
|
||||
- [x] 18.2 验证 BOARD-3 环比开关行为
|
||||
- `compare=0` 时响应 JSON 中无 Compare/Down/Flat 字段 ✅
|
||||
- `compare=1` 时响应 JSON 中包含完整环比数据 ✅
|
||||
- `area≠all` 时 `recharge` 为 null ✅
|
||||
- [x] 18.3 验证 BOARD-1 参数互斥
|
||||
- `time=last_6m` + `sort=sv_desc` 返回 HTTP 400 ✅
|
||||
- [x] 18.4 验证 BOARD-2 分页行为
|
||||
- `page=1, pageSize=20` 返回正确分页结构 ✅
|
||||
- 不同 page 返回的 total 一致 ✅
|
||||
- [x] 18.5 小程序前端联调验证
|
||||
- 前端筛选修复代码已正确接入 API(代码审查确认)
|
||||
- 待联调清单记录在测试文件注释中(FDW 列名已修复,可联调)
|
||||
|
||||
- [x] 19. 项目文档更新与落地
|
||||
- [x] 19.1 更新 `docs/contracts/openapi/backend-api.json`,补充 BOARD-1、BOARD-2、BOARD-3、CONFIG-1 四个端点的 OpenAPI 定义
|
||||
- [x] 19.2 更新 `docs/architecture/backend-architecture.md`,补充新增的 `board_service` 模块、`xcx_board` / `xcx_config` 路由注册说明
|
||||
- [x] 19.3 更新 `docs/database/BD_Manual_biz_tables.md`,补充本次引用的 `biz.coach_tasks` 表在看板场景下的使用说明(BOARD-1 task 维度查询)
|
||||
- [x] 19.4 更新 `docs/DOCUMENTATION-MAP.md`,确保新增文档条目已索引
|
||||
- [x] 19.5 更新 `docs/miniprogram-dev/API-contract.md`,补充 BOARD-1、BOARD-2、BOARD-3、CONFIG-1 的接口契约(请求参数/响应示例)
|
||||
|
||||
- [x] 20. 数据库变更审计与 DDL 合并
|
||||
- [x] 20.1 审计本次实现中对数据库的改动(新建表、新增字段、新增索引、FDW 映射变更等)
|
||||
- 结论:**无 DDL 变更**。全部基于已有 `app.v_*` RLS 视图的 SELECT 查询,`IMPORT FOREIGN SCHEMA app` 已自动导入所有视图。`biz.coach_tasks` 看板查询走已有 `idx_coach_tasks_assistant_status` 索引,无需新增。
|
||||
- [x] 20.2 将所有数据库变更合并到主 DDL 文件
|
||||
- 结论:无 DDL 变更需合并。
|
||||
- [x] 20.3 更新 BD 手册记录变更
|
||||
- `docs/database/BD_Manual_biz_tables.md` 已补充 RNS1.3 看板引用说明(§2.1)
|
||||
- 审计记录:`docs/audit/changes/2026-03-20__rns13-board-apis-e2e-fix.md`
|
||||
1
.kiro/specs/rns1-customer-coach-api/.config.kiro
Normal file
1
.kiro/specs/rns1-customer-coach-api/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "b2f4e8a1-3c7d-4f9b-a6e2-8d5c1b3f7a9e", "workflowType": "requirements-first", "specType": "feature"}
|
||||
1068
.kiro/specs/rns1-customer-coach-api/design.md
Normal file
1068
.kiro/specs/rns1-customer-coach-api/design.md
Normal file
File diff suppressed because it is too large
Load Diff
253
.kiro/specs/rns1-customer-coach-api/requirements.md
Normal file
253
.kiro/specs/rns1-customer-coach-api/requirements.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# 需求文档 — RNS1.2:客户与助教接口
|
||||
|
||||
## 简介
|
||||
|
||||
RNS1.2 是 NS1 小程序后端 API 补全项目的第三个子 spec,负责实现客户详情(CUST-1)、客户服务记录(CUST-2)、助教详情(COACH-1)3 个接口。这三个接口覆盖客户视角和助教视角的详情查看需求,是走查报告中 Gap 最集中的区域(GAP-23~30、GAP-32~35、GAP-38~44),数据结构最为复杂。
|
||||
|
||||
### 依赖
|
||||
|
||||
- RNS1.0(基础设施与契约重写)已完成:全局响应包装中间件(`ResponseWrapperMiddleware`)、camelCase 转换(`CamelModel`)、重写后的 API 契约
|
||||
- RNS1.1(任务与绩效接口)可并行开发,无直接依赖
|
||||
- 后端已有 `fdw_queries.py`(FDW 查询集中封装)、`task_manager.py`、`note_service.py`
|
||||
- 前端已有 13 个页面(P5.2 交付),当前使用 mock 数据
|
||||
|
||||
### 来源文档
|
||||
|
||||
- `docs/prd/Neo_Specs/RNS1-split-plan.md` — 拆分计划主文档(RNS1.2 章节)
|
||||
- `docs/miniprogram-dev/API-contract.md` — API 契约(CUST-1、CUST-2、COACH-1 完整定义)
|
||||
- `docs/reports/storyboard-walkthrough-assistant-view.md` — 助教视角走查报告(GAP-23~30、GAP-32~35、GAP-38~44)
|
||||
- `docs/reports/miniprogram-storyboard-walkthrough-gaps.md` — 管理层视角走查报告(G4、G5)
|
||||
- `docs/reports/DWD-DOC/` — 金额口径与字段语义权威标杆文档
|
||||
- `docs/architecture/backend-architecture.md` — 后端架构文档
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Backend**:FastAPI 后端应用,位于 `apps/backend/`
|
||||
- **Miniprogram**:微信小程序前端应用,位于 `apps/miniprogram/`
|
||||
- **CUST_1_API**:客户详情接口 `GET /api/xcx/customers/{customerId}`,返回客户完整详情(Banner 概览、AI 洞察、关联助教任务、最亲密助教、消费记录、备注)
|
||||
- **CUST_2_API**:客户服务记录接口 `GET /api/xcx/customers/{customerId}/records`,返回按月查询的服务记录列表
|
||||
- **COACH_1_API**:助教详情接口 `GET /api/xcx/coaches/{coachId}`,返回助教完整详情(绩效、收入、档位、TOP 客户、历史月份、任务分组、备注)
|
||||
- **FDW**:PostgreSQL Foreign Data Wrapper,后端通过直连 ETL 库查询 `app.v_*` RLS 视图
|
||||
- **items_sum**:DWD-DOC 强制使用的消费金额口径,= `table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money`
|
||||
- **assistant_pd_money**:助教陪打费用(基础课),DWD-DOC 强制规则 2 要求的拆分字段
|
||||
- **assistant_cx_money**:助教超休费用(激励课),DWD-DOC 强制规则 2 要求的拆分字段
|
||||
- **v_dim_member**:ETL RLS 视图,提供会员基本信息(nickname、mobile),通过 `member_id` 关联,取 `scd2_is_current=1`
|
||||
- **v_dim_member_card_account**:ETL RLS 视图,提供会员卡余额,通过 `tenant_member_id` 关联,取 `scd2_is_current=1`
|
||||
- **v_dwd_assistant_service_log**:ETL RLS 视图,提供助教服务记录明细(基于 `dwd_assistant_service_log` 基表,废单字段为 `is_delete`)
|
||||
- **v_dws_member_assistant_relation_index**:ETL RLS 视图,提供会员与助教的关系指数
|
||||
- **v_dws_member_consumption_summary**:ETL RLS 视图,提供会员消费汇总
|
||||
- **v_dws_assistant_salary_calc**:ETL RLS 视图,提供助教绩效/档位/收入数据
|
||||
- **v_dim_assistant**:ETL RLS 视图,提供助教基本信息
|
||||
- **v_dwd_table_fee_log**:ETL RLS 视图,提供台费明细
|
||||
- **ai_cache**:业务库 `biz.ai_cache` 表,按 `cache_type` 存储不同类型的 AI 分析缓存
|
||||
- **coach_tasks**:业务库 `biz.coach_tasks` 表,存储助教任务分配与状态
|
||||
- **member_retention_clue**:业务库 `public.member_retention_clue` 表,存储维客线索
|
||||
- **user_assistant_binding**:认证库 `auth.user_assistant_binding` 表,映射小程序用户与助教身份
|
||||
- **settle_type**:结算类型字段,正向交易取 `IN (1, 3)`
|
||||
- **is_delete**:RLS 视图中的废单标记字段(整数类型,0=正常),对应 design.md 中的 `is_trash`
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:实现 CUST-1 客户详情 Banner 概览(T2-1 基础部分)
|
||||
|
||||
**用户故事:** 作为管理者或助教,我希望在客户详情页顶部看到客户的基本信息和关键指标(余额、近期消费、到店间隔、距上次到店天数),以便快速评估客户价值和活跃度。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CUST_1_API SHALL 返回客户基础信息字段:`id`(客户唯一 ID)、`name`(客户姓名)、`phone`(脱敏手机号,如 `"139****5678"`)、`phoneFull`(完整手机号)、`avatar`(头像 URL)、`memberLevel`(会员等级)、`relationIndex`(关系指数)、`tags`(客户标签列表)
|
||||
2. THE CUST_1_API SHALL 通过 `member_id` LEFT JOIN `v_dim_member`(取 `scd2_is_current=1`)获取 `name`(`nickname` 字段)和 `phone`/`phoneFull`(`mobile` 字段),禁止使用 `settlement_head.member_phone`(DQ-6)
|
||||
3. THE CUST_1_API SHALL 通过 `member_id` LEFT JOIN `v_dim_member_card_account`(`tenant_member_id=member_id`,取 `scd2_is_current=1`)获取 `memberLevel`,禁止使用 `member_card_type_name`(DQ-7)
|
||||
4. THE CUST_1_API SHALL 返回 Banner 概览字段:`balance`(客户余额,元)、`consumption60d`(近 60 天消费金额,元)、`idealInterval`(理想到店间隔,天)、`daysSinceVisit`(距上次到店天数)
|
||||
5. THE CUST_1_API SHALL 使用 `items_sum` 口径计算 `balance` 和 `consumption60d`(DWD-DOC 强制规则 1),禁止使用 `consume_money`
|
||||
6. THE CUST_1_API SHALL 从 `v_dwd_assistant_service_log` 查询客户最后到店日期(`MAX(create_time)`,过滤 `is_delete=0`),计算 `daysSinceVisit`(当前日期与最后到店日期的天数差)
|
||||
7. IF 某个 Banner 字段的数据源查询失败或无数据,THEN THE CUST_1_API SHALL 对该字段返回 `null`,不影响其他字段和整体响应
|
||||
|
||||
### 需求 2:实现 CUST-1 AI 洞察模块(T2-1 AI 部分)
|
||||
|
||||
**用户故事:** 作为管理者或助教,我希望在客户详情页看到 AI 生成的客户分析洞察和策略建议,以便制定针对性的服务策略。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CUST_1_API SHALL 返回 `aiInsight` 字段,包含 `summary`(AI 分析摘要文本)和 `strategies`(策略建议列表,每项含 `color` 和 `text`)
|
||||
2. THE CUST_1_API SHALL 从 `biz.ai_cache` 查询 `cache_type='app4_analysis'` 且 `target_id=customerId` 的缓存记录,解析 `cache_value` JSON 生成 `aiInsight` 数据
|
||||
3. IF `biz.ai_cache` 中无对应缓存记录,THEN THE CUST_1_API SHALL 返回 `aiInsight: { summary: "", strategies: [] }`
|
||||
|
||||
### 需求 3:实现 CUST-1 维客线索与备注(T2-1 线索与备注部分)
|
||||
|
||||
**用户故事:** 作为管理者或助教,我希望在客户详情页看到维客线索和历史备注,以便了解客户的留存风险和过往沟通记录。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CUST_1_API SHALL 返回 `retentionClues` 字段(维客线索列表),从 `public.member_retention_clue` 查询,按 `created_at` 倒序排列,格式与 TASK-2 一致
|
||||
2. THE CUST_1_API SHALL 返回 `notes` 字段(备注列表),从 `biz.notes` 查询 `target_type='member'` 且 `target_id=customerId` 的记录,每项含 `id`、`tagLabel`、`createdAt`、`content`,按 `created_at` 倒序排列,最多返回 20 条
|
||||
|
||||
### 需求 4:实现 CUST-1 消费记录嵌套结构(T2-1 消费记录部分)
|
||||
|
||||
**用户故事:** 作为管理者或助教,我希望在客户详情页看到消费记录的完整拆分(台费、酒水、助教服务明细),以便分析客户的消费构成和偏好。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CUST_1_API SHALL 返回 `consumptionRecords` 字段(消费记录列表),每条记录包含嵌套结构:`id`、`type`(`table`/`shop`/`recharge`)、`date`、`tableName`、`startTime`、`endTime`、`duration`、`tableFee`、`tableOrigPrice`、`coaches`(助教服务子数组)、`foodAmount`、`foodOrigPrice`、`totalAmount`、`totalOrigPrice`、`payMethod`、`rechargeAmount`
|
||||
2. THE CUST_1_API SHALL 为每条消费记录的 `coaches` 子数组返回助教服务明细,每项含 `name`、`level`、`levelColor`、`courseType`(`"基础课"` 或 `"激励课"`)、`hours`(服务时长)、`perfHours`(折算工时,可选)、`fee`(服务费用)
|
||||
3. THE CUST_1_API SHALL 对 `coaches` 子数组中的 `fee` 字段使用 `assistant_pd_money`(基础课)和 `assistant_cx_money`(激励课)拆分(DWD-DOC 强制规则 2),禁止使用 `service_fee`
|
||||
4. THE CUST_1_API SHALL 对 `totalAmount` 使用 `items_sum` 口径(DWD-DOC 强制规则 1),对 `tableFee` 使用 `table_charge_money`,对 `foodAmount` 使用 `goods_money`
|
||||
5. THE CUST_1_API SHALL 仅查询正向交易记录(`settle_type IN (1, 3)`)
|
||||
6. THE CUST_1_API SHALL 使用 `v_dwd_assistant_service_log` 的 `is_delete=0` 排除废单记录
|
||||
|
||||
### 需求 5:实现 CUST-1 coachTasks 模块(T2-2)
|
||||
|
||||
**用户故事:** 作为管理者或助教,我希望在客户详情页看到所有关联助教的任务信息和近期服务统计,以便了解该客户被哪些助教跟进、服务频率和质量如何。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CUST_1_API SHALL 返回 `coachTasks` 字段(关联助教任务列表),从 `biz.coach_tasks` 查询该客户(`member_id=customerId`)的所有任务记录
|
||||
2. THE CUST_1_API SHALL 为每位关联助教返回以下字段:`name`(助教姓名)、`level`(助教等级:`star`/`senior`/`middle`/`junior`)、`levelColor`(等级对应颜色)、`taskType`(任务类型,如 `"回访"`、`"召回"`)、`taskColor`(任务类型对应颜色)、`bgClass`(背景样式类)、`status`(任务状态)、`lastService`(最后服务日期)、`metrics`(指标列表)
|
||||
3. THE CUST_1_API SHALL 为每位助教的 `metrics` 返回近 60 天统计:服务次数、总时长、次均时长,从 `v_dwd_assistant_service_log` 按助教+客户聚合近 60 天数据(过滤 `is_delete=0`)
|
||||
4. THE CUST_1_API SHALL 从 `v_dws_assistant_salary_calc` 获取助教等级(`assistant_level_name`),从 `v_dim_member` 获取助教姓名(DQ-6)
|
||||
|
||||
### 需求 6:实现 CUST-1 favoriteCoaches 模块(T2-3)
|
||||
|
||||
**用户故事:** 作为管理者或助教,我希望在客户详情页看到该客户最亲密的助教排名和详细服务统计,以便了解客户的助教偏好和关系深度。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CUST_1_API SHALL 返回 `favoriteCoaches` 字段(最亲密助教列表),从 `v_dws_member_assistant_relation_index` 获取关系指数,按关系指数降序排列
|
||||
2. THE CUST_1_API SHALL 为每位亲密助教返回:`emoji`(亲密度 emoji)、`name`(助教姓名)、`relationIndex`(关系指数,如 `"0.92"`)、`indexColor`(关系指数对应颜色)、`bgClass`(背景样式类)、`stats`(统计指标列表)
|
||||
3. THE CUST_1_API SHALL 为 `stats` 返回 4 项指标:基础课时(对应 `assistant_pd_money`)、激励课时(对应 `assistant_cx_money`)、上课次数、充值金额,使用 DWD-DOC 强制规则 2 拆分助教费用
|
||||
4. THE CUST_1_API SHALL 根据关系指数(`rs_display`,0-10 范围)阈值映射 `emoji`(P6 AC3 四级映射):`> 8.5` → `"💖"`,`> 7` → `"🧡"`,`> 5` → `"💛"`,`≤ 5` → `"💙"`,复用后端 `compute_heart_icon()` 函数
|
||||
|
||||
### 需求 7:实现 CUST-2 客户服务记录(T2-4)
|
||||
|
||||
**用户故事:** 作为管理者或助教,我希望按月查看客户的服务记录(替代前端全量加载本地过滤),以便高效浏览大量历史数据并查看月度统计汇总。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CUST_2_API SHALL 接受 `year`、`month`、`table`(可选,台桌筛选)查询参数,返回指定月份的客户服务记录
|
||||
2. THE CUST_2_API SHALL 返回客户基础信息:`customerName`(客户姓名)、`customerPhone`(脱敏手机号)、`customerPhoneFull`(完整手机号)、`relationIndex`(关系指数)、`tables`(可选台桌列表)
|
||||
3. THE CUST_2_API SHALL 通过 `member_id` LEFT JOIN `v_dim_member`(取 `scd2_is_current=1`)获取 `customerName` 和 `customerPhone`/`customerPhoneFull`(DQ-6)
|
||||
4. THE CUST_2_API SHALL 返回 `totalServiceCount`(累计服务总次数,跨所有月份)
|
||||
5. THE CUST_2_API SHALL 为每条服务记录返回 `recordType`(`course` 或 `recharge`)和 `isEstimate`(是否预估数据,boolean)字段
|
||||
6. THE CUST_2_API SHALL 返回月度统计汇总:`monthCount`(当月服务次数)和 `monthHours`(当月总工时)
|
||||
7. THE CUST_2_API SHALL 使用 `items_sum` 口径计算服务记录中的 `income` 字段(DWD-DOC 强制规则 1)
|
||||
8. THE CUST_2_API SHALL 使用 `is_delete=0` 排除废单记录
|
||||
9. THE CUST_2_API SHALL 按 `create_time` 倒序排列服务记录,返回 `hasMore` 标记指示是否有更多数据
|
||||
|
||||
|
||||
### 需求 8:实现 COACH-1 助教详情基础信息与绩效(T2-5 基础部分)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在助教详情页看到助教的基本信息和 6 项绩效指标,以便快速评估助教的工作表现和产出。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE COACH_1_API SHALL 返回助教基础信息字段:`id`、`name`、`avatar`、`level`(初级/中级/高级/星级)、`skills`(技能标签列表)、`workYears`(工龄,年)、`customerCount`(客户数)、`hireDate`(入职日期)
|
||||
2. THE COACH_1_API SHALL 从 `v_dim_assistant` 获取助教基本信息,从 `v_dws_assistant_salary_calc` 获取等级(`assistant_level_name`)
|
||||
3. THE COACH_1_API SHALL 返回 `performance` 字段,包含 6 项绩效指标:`monthlyHours`(本月定档工时)、`monthlySalary`(本月工资预估)、`customerBalance`(客源储值余额合计)、`tasksCompleted`(本月任务完成数)、`perfCurrent`(当前绩效值)、`perfTarget`(绩效目标值)
|
||||
4. THE COACH_1_API SHALL 从 `v_dws_assistant_salary_calc` 查询 `monthlyHours`(`effective_hours`)和 `monthlySalary`(`gross_salary`),使用 `items_sum` 口径(DWD-DOC 强制规则 1)
|
||||
5. THE COACH_1_API SHALL 从 `biz.coach_tasks` 查询 `tasksCompleted`(当月 `status='completed'` 的任务数)
|
||||
|
||||
### 需求 9:实现 COACH-1 收入明细与档位节点(T2-5 收入部分)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在助教详情页看到本月和上月的收入明细拆分(基础课时费、激励课时费、充值提成、酒水提成)以及档位进度节点,以便了解助教的收入构成和升档进度。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE COACH_1_API SHALL 返回 `income` 字段,包含 `thisMonth` 和 `lastMonth` 两个子数组,各含 4 项收入分类:基础课时费(`assistant_pd_money`)、激励课时费(`assistant_cx_money`)、充值提成、酒水提成
|
||||
2. THE COACH_1_API SHALL 使用 `assistant_pd_money`(基础课/陪打)和 `assistant_cx_money`(激励课/超休)拆分助教费用(DWD-DOC 强制规则 2),禁止使用 `service_fee`
|
||||
3. THE COACH_1_API SHALL 从 `v_dws_assistant_salary_calc` 分别查询当月和上月的收入数据(`salary_month` 字段为 date 类型,存储为 `YYYY-MM-01`)
|
||||
4. THE COACH_1_API SHALL 返回 `tierNodes` 字段(档位节点数组,如 `[0, 100, 130, 160, 190, 220]`),供前端绩效进度条组件使用
|
||||
|
||||
### 需求 10:实现 COACH-1 TOP 客户与近期服务记录(T2-5 客户部分)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在助教详情页看到该助教的 TOP 客户排名(含关系指数、余额、消费)和近期服务明细(含折算工时),以便评估助教的客户关系质量和服务产出。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE COACH_1_API SHALL 返回 `topCustomers` 字段(TOP 客户列表,最多 20 条),每项含扩展字段:`id`、`name`、`initial`(姓氏首字)、`avatarGradient`(头像渐变色)、`heartEmoji`(关系 emoji,P6 AC3 四级映射:💖/🧡/💛/💙)、`relationScore`(关系指数,0-10)、`scoreColor`(分数颜色)、`serviceCount`(服务次数)、`balance`(余额,格式化)、`consume`(消费总额,格式化)
|
||||
2. THE COACH_1_API SHALL 对 `topCustomers[].consume` 使用 `items_sum` 口径(DWD-DOC 强制规则 1)
|
||||
3. THE COACH_1_API SHALL 对 `topCustomers[].balance` 通过 `member_id` LEFT JOIN `v_dim_member_card_account`(取 `scd2_is_current=1`)获取(DQ-7)
|
||||
4. THE COACH_1_API SHALL 通过 `member_id` LEFT JOIN `v_dim_member`(取 `scd2_is_current=1`)获取客户姓名(DQ-6)
|
||||
5. THE COACH_1_API SHALL 返回 `serviceRecords` 字段(近期服务记录列表),每项含 `customerId`、`customerName`、`initial`、`avatarGradient`、`type`(课程类型)、`typeClass`(样式类)、`table`(台桌名)、`duration`(时长)、`income`(收入)、`date`(日期时间)、`perfHours`(折算工时,可选)
|
||||
6. THE COACH_1_API SHALL 对 `serviceRecords[].income` 使用 `ledger_amount`(对应 `items_sum` 口径,DWD-DOC 强制规则 1)
|
||||
7. THE COACH_1_API SHALL 使用 `is_delete=0` 排除废单记录
|
||||
|
||||
### 需求 11:实现 COACH-1 任务分组与备注(T2-5 任务部分)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在助教详情页看到该助教的任务按状态分组展示(进行中/已过期/已放弃),每个任务含关联备注和放弃原因,以便全面了解助教的任务执行情况。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE COACH_1_API SHALL 返回任务分为三组:`visibleTasks`(active 状态任务)、`hiddenTasks`(inactive 状态任务)、`abandonedTasks`(abandoned 状态任务),从 `biz.coach_tasks` 查询该助教的所有任务
|
||||
2. THE COACH_1_API SHALL 为 `visibleTasks` 和 `hiddenTasks` 每项返回:`typeLabel`(任务类型标签)、`typeClass`(样式类)、`customerName`(客户姓名)、`customerId`(客户 ID,用于跳转)、`noteCount`(备注数量)、`pinned`(是否置顶)、`notes`(备注列表,可选,每项含 `pinned`、`text`、`date`)
|
||||
3. THE COACH_1_API SHALL 为 `abandonedTasks` 每项返回:`customerName`(客户姓名)、`reason`(放弃原因,来自 `coach_tasks.abandon_reason`)
|
||||
4. THE COACH_1_API SHALL 从 `biz.notes` 查询每个任务关联的备注(`task_id` 关联),按 `created_at` 倒序排列
|
||||
5. THE COACH_1_API SHALL 返回 `notes` 字段(助教相关备注列表),每项含 `id`、`content`、`timestamp`、`score`、`customerName`、`tagLabel`、`createdAt`,按 `created_at` 倒序排列,最多返回 20 条
|
||||
|
||||
### 需求 12:实现 COACH-1 historyMonths 模块(T2-6)
|
||||
|
||||
**用户故事:** 作为管理者,我希望在助教详情页看到该助教最近 5 个以上月份的历史统计(客户数、工时、工资、回访/召回完成数),以便追踪助教的长期表现趋势。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE COACH_1_API SHALL 返回 `historyMonths` 字段(历史月份统计列表),包含最近 5 个以上月份的汇总数据,第一条为本月
|
||||
2. THE COACH_1_API SHALL 为每个月份返回:`month`(月份标签,如 `"本月"`、`"上月"`、`"4月"`)、`estimated`(是否为预估数据,boolean)、`customers`(客户数,格式化,如 `"22人"`)、`hours`(工时,格式化,如 `"87.5h"`)、`salary`(工资,格式化,如 `"¥6,950"`)、`callbackDone`(回访任务完成数)、`recallDone`(召回任务完成数)
|
||||
3. THE COACH_1_API SHALL 从 `v_dws_assistant_salary_calc` 查询各月的工时(`effective_hours`)和工资(`gross_salary`)数据
|
||||
4. THE COACH_1_API SHALL 从 `biz.coach_tasks` 查询各月的回访完成数(`task_type='follow_up_visit' AND status='completed'`)和召回完成数(`task_type IN ('high_priority_recall', 'priority_recall') AND status='completed'`)
|
||||
5. THE COACH_1_API SHALL 将本月标记为 `estimated: true`(预估数据),历史月份标记为 `estimated: false`
|
||||
6. THE COACH_1_API SHALL 从 `v_dwd_assistant_service_log` 按月统计不重复的 `tenant_member_id` 数量作为客户数(过滤 `is_delete=0`)
|
||||
|
||||
### 需求 13:全局约束与数据隔离
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望所有客户和助教接口都遵循统一的权限控制、数据隔离和数据质量规则,以确保数据安全和口径一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 13.1 权限与认证
|
||||
|
||||
1. THE Backend SHALL 对所有 RNS1.2 接口(CUST_1_API、CUST_2_API、COACH_1_API)执行 `require_approved()` 权限检查,确保用户状态为 `approved`
|
||||
2. THE Backend SHALL 对所有 RNS1.2 接口通过 `SET LOCAL app.current_site_id` 实现门店级数据隔离(FDW 查询通过 `_fdw_context` 上下文管理器统一执行)
|
||||
|
||||
#### 13.2 DWD-DOC 强制规则
|
||||
|
||||
3. THE Backend SHALL 对所有涉及金额的字段统一使用 `items_sum` 口径(DWD-DOC 强制规则 1),禁止使用 `consume_money`
|
||||
4. THE Backend SHALL 对所有涉及助教费用的字段使用 `assistant_pd_money`(陪打/基础课)+ `assistant_cx_money`(超休/激励课)拆分(DWD-DOC 强制规则 2),禁止使用 `service_fee`
|
||||
5. THE Backend SHALL 对所有涉及会员信息的查询通过 `member_id` LEFT JOIN `v_dim_member`(取 `scd2_is_current=1`)获取姓名和手机号(DWD-DOC 强制规则 DQ-6),禁止直接使用 `settlement_head.member_phone` 或 `member_name`
|
||||
6. THE Backend SHALL 对所有涉及会员卡信息的查询通过 `member_id` LEFT JOIN `v_dim_member_card_account`(`tenant_member_id=member_id`,取 `scd2_is_current=1`)获取(DWD-DOC 强制规则 DQ-7),禁止使用 `member_card_type_name`
|
||||
7. THE Backend SHALL 使用 `v_dwd_assistant_service_log` 的 `is_delete=0` 排除废单记录,禁止使用已废弃的 `dwd_assistant_trash_event` 表
|
||||
|
||||
#### 13.3 优雅降级
|
||||
|
||||
8. IF 某个扩展模块(`aiInsight`/`coachTasks`/`favoriteCoaches`/`historyMonths`)的数据源查询失败,THEN THE Backend SHALL 对该模块返回空默认值(空数组或空对象),不影响其他模块和整体响应
|
||||
9. THE Backend SHALL 对所有 FDW 查询异常进行捕获和日志记录,返回降级响应而非 HTTP 500
|
||||
|
||||
#### 13.4 列名映射
|
||||
|
||||
10. THE Backend SHALL 在 SQL 中使用 AS 别名将 RLS 视图原始列名转换为代码语义名(如 `site_assistant_id AS assistant_id`、`tenant_member_id AS member_id`、`create_time AS settle_time`、`ledger_amount AS income`、`income_seconds / 3600.0 AS service_hours`),统一在 `fdw_queries.py` 中封装
|
||||
|
||||
### 需求 14:正确性属性(Property-Based Testing)
|
||||
|
||||
**用户故事:** 作为开发者,我希望通过属性测试验证接口的数据一致性和业务规则正确性,以便在开发阶段发现口径错误和数据异常。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 14.1 金额口径不变量
|
||||
|
||||
1. FOR ALL 消费记录,THE CUST_1_API 返回的 `totalAmount` SHALL 等于 `tableFee + foodAmount + SUM(coaches[].fee)`(在浮点精度范围内),验证 `items_sum` 口径拆分的一致性
|
||||
2. FOR ALL 助教费用拆分,`coaches[].fee` 中基础课记录 SHALL 对应 `assistant_pd_money`,激励课记录 SHALL 对应 `assistant_cx_money`,两者之和 SHALL 等于该结算单的助教费用总额
|
||||
|
||||
#### 14.2 数据隔离不变量
|
||||
|
||||
3. FOR ALL CUST_1_API 响应中的 `coachTasks`,每条任务的 `member_id` SHALL 等于请求路径中的 `customerId`,验证客户-任务关联的正确性
|
||||
4. FOR ALL COACH_1_API 响应中的 `serviceRecords`,每条记录的 `assistant_id` SHALL 等于请求路径中的 `coachId`,验证助教数据隔离
|
||||
|
||||
#### 14.3 排序与分组不变量
|
||||
|
||||
5. FOR ALL CUST_1_API 响应中的 `favoriteCoaches`,列表 SHALL 按 `relationIndex` 降序排列(前一项的 `relationIndex` ≥ 后一项的 `relationIndex`)
|
||||
6. FOR ALL COACH_1_API 响应中的 `historyMonths`,列表 SHALL 按月份降序排列(最近月份在前),且第一条的 `estimated` SHALL 为 `true`
|
||||
|
||||
#### 14.4 幂等性
|
||||
|
||||
7. FOR ALL 相同参数的 CUST_2_API 请求(相同 `customerId`、`year`、`month`),在数据未变更的情况下,两次请求 SHALL 返回相同的 `monthCount` 和 `monthHours` 值
|
||||
|
||||
#### 14.5 废单排除一致性
|
||||
|
||||
8. FOR ALL 服务记录查询,返回的记录集合中 SHALL 不包含 `is_delete != 0` 的记录,验证废单排除规则在所有接口中一致执行
|
||||
271
.kiro/specs/rns1-customer-coach-api/tasks.md
Normal file
271
.kiro/specs/rns1-customer-coach-api/tasks.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Implementation Plan: RNS1.2 客户与助教接口
|
||||
|
||||
## Overview
|
||||
|
||||
基于 design.md 架构,按 T2-1 ~ T2-6 任务结构增量实现 CUST-1、CUST-2、COACH-1 三个接口。先扩展 FDW 查询层,再逐步构建 service → router → 集成测试 → 属性测试。所有金额使用 `items_sum` 口径,助教费用使用 `assistant_pd_money` + `assistant_cx_money` 拆分,会员信息通过 `member_id` JOIN `v_dim_member`。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Pydantic Schema 定义与项目结构搭建
|
||||
- [x] 1.1 创建 `apps/backend/app/schemas/xcx_customers.py`,定义 CUST-1 和 CUST-2 所有响应模型
|
||||
- `CustomerDetailResponse`、`CustomerRecordsResponse` 及所有嵌套模型(`AiInsight`、`AiStrategy`、`MetricItem`、`CoachTask`、`FavoriteCoach`、`CoachServiceItem`、`ConsumptionRecord`、`RetentionClue`、`CustomerNote`、`ServiceRecordItem`)
|
||||
- 所有模型继承 `CamelModel`,确保 camelCase 序列化
|
||||
- _Requirements: 1.1-1.7, 2.1-2.3, 3.1-3.2, 4.1-4.6, 5.1-5.4, 6.1-6.4, 7.1-7.9_
|
||||
- [x] 1.2 创建 `apps/backend/app/schemas/xcx_coaches.py`,定义 COACH-1 所有响应模型
|
||||
- `CoachDetailResponse` 及所有嵌套模型(`PerformanceMetrics`、`IncomeItem`、`IncomeSection`、`CoachTaskItem`、`AbandonedTask`、`TopCustomer`、`CoachServiceRecord`、`HistoryMonth`、`CoachNoteItem`)
|
||||
- _Requirements: 8.1-8.5, 9.1-9.4, 10.1-10.7, 11.1-11.5, 12.1-12.6_
|
||||
|
||||
- [x] 2. FDW 查询层扩展(T2-1 基础)
|
||||
- [x] 2.1 在 `apps/backend/app/services/fdw_queries.py` 新增客户相关查询函数
|
||||
- `get_consumption_60d(conn, site_id, member_id)` — 近 60 天消费,使用 `ledger_amount`(items_sum),过滤 `is_delete=0`
|
||||
- `get_relation_index(conn, site_id, member_id)` — 关系指数列表,来源 `v_dws_member_assistant_relation_index`,按 `relation_index` 降序
|
||||
- `get_consumption_records(conn, site_id, member_id, limit, offset)` — 消费记录嵌套查询,JOIN `v_dim_assistant`,过滤 `settle_type IN (1,3)` + `is_delete=0`
|
||||
- `get_total_service_count(conn, site_id, member_id)` — 累计服务总次数
|
||||
- `get_coach_60d_stats(conn, site_id, assistant_id, member_id)` — 特定助教对特定客户近 60 天统计
|
||||
- 所有 SQL 使用 AS 别名映射(design.md 列名映射表)
|
||||
- _Requirements: 1.5, 1.6, 4.3-4.6, 5.3, 6.1, 7.4, 7.7-7.8, 13.2-13.7, 13.10_
|
||||
- [x] 2.2 在 `apps/backend/app/services/fdw_queries.py` 新增助教相关查询函数
|
||||
- `get_assistant_info(conn, site_id, assistant_id)` — 助教基本信息,来源 `v_dim_assistant`
|
||||
- `get_salary_calc_multi_months(conn, site_id, assistant_id, months)` — 批量多月绩效数据
|
||||
- `get_monthly_customer_count(conn, site_id, assistant_id, months)` — 各月不重复客户数,`COUNT(DISTINCT tenant_member_id)`,过滤 `is_delete=0`
|
||||
- `get_coach_top_customers(conn, site_id, assistant_id, limit=20)` — TOP 客户,JOIN `v_dim_member`(DQ-6)+ `v_dim_member_card_account`(DQ-7),consume 使用 `ledger_amount`
|
||||
- `get_customer_service_records(conn, site_id, member_id, year, month, table, limit, offset)` — 按月服务记录 + 月度统计汇总
|
||||
- _Requirements: 8.2, 8.4, 9.3, 10.2-10.4, 10.6-10.7, 12.3, 12.6, 13.5-13.7, 13.10_
|
||||
- [x] 2.3 为新增 FDW 查询函数编写单元测试
|
||||
- 测试文件:`apps/backend/tests/unit/test_fdw_queries_rns12.py`
|
||||
- 验证 DQ-6 JOIN 正确性、DQ-7 余额查询、`is_delete=0` 排除、`items_sum` 口径
|
||||
- _Requirements: 13.2-13.7_
|
||||
|
||||
- [x] 3. CUST-1 客户详情 Service + Router(T2-1 ~ T2-3)
|
||||
- [x] 3.1 创建 `apps/backend/app/services/customer_service.py`,实现 `get_customer_detail()`
|
||||
- 核心字段:调用 `fdw_queries.get_member_info()` → 基础信息,`get_member_balance()` → balance,`get_consumption_60d()` → consumption60d,`get_last_visit_days()` → daysSinceVisit
|
||||
- 手机号脱敏逻辑(`"139****5678"` 格式)
|
||||
- Banner 字段查询失败返回 `null`(需求 1.7)
|
||||
- _Requirements: 1.1-1.7, 13.1-13.2_
|
||||
- [x] 3.2 在 `customer_service.py` 实现 `_build_ai_insight()` 和 `_build_retention_clues()` 和 `_build_notes()`
|
||||
- aiInsight:查询 `biz.ai_cache` WHERE `cache_type='app4_analysis'` AND `target_id=customerId`,解析 `cache_value` JSON
|
||||
- retentionClues:查询 `public.member_retention_clue`,按 `created_at` 倒序
|
||||
- notes:查询 `biz.notes` WHERE `target_type='member'`,最多 20 条,按 `created_at` 倒序
|
||||
- 每个模块独立 try/except 优雅降级
|
||||
- _Requirements: 2.1-2.3, 3.1-3.2, 13.8-13.9_
|
||||
- [x] 3.3 在 `customer_service.py` 实现 `_build_consumption_records()`
|
||||
- 调用 `fdw_queries.get_consumption_records()` 获取结算单列表
|
||||
- 构建 coaches 子数组:`fee` 使用 `assistant_pd_money`(基础课)/ `assistant_cx_money`(激励课)
|
||||
- `totalAmount` 使用 `items_sum` 口径,`tableFee` 使用 `table_charge_money`,`foodAmount` 使用 `goods_money`
|
||||
- 过滤 `settle_type IN (1, 3)` + `is_delete=0`
|
||||
- _Requirements: 4.1-4.6, 13.3-13.4, 13.7_
|
||||
- [x] 3.4 在 `customer_service.py` 实现 `_build_coach_tasks()`(T2-2)
|
||||
- 查询 `biz.coach_tasks` WHERE `member_id=customerId`
|
||||
- 对每位助教:`fdw_queries.get_salary_calc()` 获取等级,`get_coach_60d_stats()` 获取近 60 天统计
|
||||
- 映射 `levelColor`/`taskColor`/`bgClass`
|
||||
- metrics 返回:服务次数、总时长、次均时长
|
||||
- _Requirements: 5.1-5.4_
|
||||
- [x] 3.5 在 `customer_service.py` 实现 `_build_favorite_coaches()`(T2-3)
|
||||
- 调用 `fdw_queries.get_relation_index()` 获取关系指数列表,按降序排列
|
||||
- emoji 映射:`relationIndex >= 0.7` → `"💖"`,`< 0.7` → `"💛"`
|
||||
- stats 4 项指标:基础课时(`assistant_pd_money`)、激励课时(`assistant_cx_money`)、上课次数、充值金额
|
||||
- _Requirements: 6.1-6.4_
|
||||
- [x] 3.6 创建 `apps/backend/app/routers/xcx_customers.py`,注册 CUST-1 端点
|
||||
- `GET /{customer_id}` → `customer_service.get_customer_detail()`
|
||||
- `Depends(require_approved())` 权限检查
|
||||
- 在 `main.py` 注册 router
|
||||
- _Requirements: 13.1_
|
||||
- [x] 3.7 为 CUST-1 编写单元测试
|
||||
- 测试文件:`apps/backend/tests/unit/test_customer_detail.py`
|
||||
- 验证完整响应结构、Banner 字段、aiInsight 降级、consumptionRecords 嵌套、coachTasks metrics、favoriteCoaches 排序
|
||||
- _Requirements: 1.1-6.4_
|
||||
|
||||
- [x] 4. Checkpoint — 确保 CUST-1 所有测试通过
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 5. CUST-2 客户服务记录 Service + Router(T2-4)
|
||||
- [x] 5.1 在 `customer_service.py` 实现 `get_customer_records()`
|
||||
- 接受 `year`、`month`、`table`(可选)、`page`、`page_size` 参数
|
||||
- 调用 `fdw_queries.get_member_info()` → customerName/customerPhone(DQ-6)
|
||||
- 调用 `fdw_queries.get_customer_service_records()` → 按月分页记录
|
||||
- 聚合 `monthCount`/`monthHours`
|
||||
- 调用 `fdw_queries.get_total_service_count()` → totalServiceCount(跨月)
|
||||
- 每条记录含 `recordType`(`course`/`recharge`)和 `isEstimate`
|
||||
- income 使用 `items_sum` 口径,排除 `is_delete!=0`
|
||||
- 按 `create_time` 倒序,返回 `hasMore`
|
||||
- _Requirements: 7.1-7.9_
|
||||
- [x] 5.2 在 `xcx_customers.py` router 注册 CUST-2 端点
|
||||
- `GET /{customer_id}/records` → `customer_service.get_customer_records()`
|
||||
- Query 参数:`year: int`、`month: int (ge=1, le=12)`、`table: str | None`、`page: int (ge=1)`、`page_size: int (ge=1, le=100)`
|
||||
- `Depends(require_approved())` 权限检查
|
||||
- _Requirements: 7.1, 13.1_
|
||||
- [x] 5.3 为 CUST-2 编写单元测试
|
||||
- 测试文件:`apps/backend/tests/unit/test_customer_records.py`
|
||||
- 验证按月查询、monthCount/monthHours 汇总、totalServiceCount 跨月、hasMore 分页、recordType/isEstimate
|
||||
- _Requirements: 7.1-7.9_
|
||||
|
||||
- [x] 6. COACH-1 助教详情 Service + Router(T2-5)
|
||||
- [x] 6.1 创建 `apps/backend/app/services/coach_service.py`,实现 `get_coach_detail()`
|
||||
- 基础信息:`fdw_queries.get_assistant_info()` → name/avatar/skills/workYears/hireDate
|
||||
- 绩效:`fdw_queries.get_salary_calc()` → monthlyHours(`effective_hours`)/monthlySalary(`gross_salary`)/perfCurrent/perfTarget
|
||||
- customerBalance:`fdw_queries.get_member_balance()` 聚合该助教所有客户余额
|
||||
- tasksCompleted:`biz.coach_tasks` 当月 `status='completed'` 计数
|
||||
- _Requirements: 8.1-8.5_
|
||||
- [x] 6.2 在 `coach_service.py` 实现 `_build_income()` 和 `_build_tier_nodes()`
|
||||
- income:`thisMonth`/`lastMonth` 各含 4 项(基础课时费 `assistant_pd_money`/`base_income`、激励课时费 `assistant_cx_money`/`bonus_income`、充值提成、酒水提成)
|
||||
- 从 `v_dws_assistant_salary_calc` 分别查询当月和上月(`salary_month` 为 `YYYY-MM-01`)
|
||||
- tierNodes:档位节点数组(如 `[0, 100, 130, 160, 190, 220]`)
|
||||
- _Requirements: 9.1-9.4_
|
||||
- [x] 6.3 在 `coach_service.py` 实现 `_build_top_customers()` 和 `_build_service_records()`
|
||||
- topCustomers:调用 `fdw_queries.get_coach_top_customers()`,最多 20 条
|
||||
- heartEmoji 三级映射:`score >= 0.7` → `"❤️"`,`0.3 <= score < 0.7` → `"💛"`,`score < 0.3` → `"🤍"`
|
||||
- consume 使用 `items_sum` 口径,balance 通过 `v_dim_member_card_account`(DQ-7),客户姓名通过 `v_dim_member`(DQ-6)
|
||||
- serviceRecords:近期服务记录,income 使用 `ledger_amount`,排除 `is_delete!=0`
|
||||
- _Requirements: 10.1-10.7_
|
||||
- [x] 6.4 在 `coach_service.py` 实现 `_build_task_groups()` 和 `_build_notes()`
|
||||
- 查询 `biz.coach_tasks` WHERE `assistant_id=coachId`
|
||||
- 按 status 分组:`active` → visibleTasks,`inactive` → hiddenTasks,`abandoned` → abandonedTasks
|
||||
- visible/hidden:关联 `biz.notes` 获取备注列表(`task_id` 关联,按 `created_at` 倒序)
|
||||
- abandoned:取 `abandon_reason`
|
||||
- notes:助教相关备注,最多 20 条
|
||||
- _Requirements: 11.1-11.5_
|
||||
- [x] 6.5 在 `coach_service.py` 实现 `_build_history_months()`(T2-6)
|
||||
- `fdw_queries.get_salary_calc_multi_months()` → 最近 6 个月工时/工资
|
||||
- `fdw_queries.get_monthly_customer_count()` → 各月客户数
|
||||
- `biz.coach_tasks` → 各月回访完成数(`task_type='follow_up_visit' AND status='completed'`)和召回完成数(`task_type IN ('high_priority_recall', 'priority_recall') AND status='completed'`)
|
||||
- 本月 `estimated=True`,历史月份 `estimated=False`
|
||||
- 格式化:customers → `"22人"`,hours → `"87.5h"`,salary → `"¥6,950"`
|
||||
- _Requirements: 12.1-12.6_
|
||||
- [x] 6.6 创建 `apps/backend/app/routers/xcx_coaches.py`,注册 COACH-1 端点
|
||||
- `GET /{coach_id}` → `coach_service.get_coach_detail()`
|
||||
- `Depends(require_approved())` 权限检查
|
||||
- 在 `main.py` 注册 router
|
||||
- _Requirements: 13.1_
|
||||
- [x] 6.7 为 COACH-1 编写单元测试
|
||||
- 测试文件:`apps/backend/tests/unit/test_coach_detail.py`
|
||||
- 验证完整响应结构、performance 6 指标、income 本月/上月、topCustomers heartEmoji、historyMonths 排序与 estimated、任务分组
|
||||
- _Requirements: 8.1-12.6_
|
||||
|
||||
- [x] 7. Checkpoint — 确保 CUST-1 + CUST-2 + COACH-1 所有测试通过
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 8. 优雅降级与权限校验测试
|
||||
- [x] 8.1 为优雅降级编写单元测试
|
||||
- 测试文件:`apps/backend/tests/unit/test_degradation_rns12.py`
|
||||
- 验证 aiInsight/coachTasks/favoriteCoaches/consumptionRecords/historyMonths 各模块查询失败时返回空默认值,不影响 HTTP 200
|
||||
- _Requirements: 13.8-13.9_
|
||||
- [x] 8.2 为权限校验编写单元测试
|
||||
- 测试文件:`apps/backend/tests/unit/test_auth_rns12.py`
|
||||
- 验证未审核用户 403、客户不存在 404、助教不存在 404
|
||||
- _Requirements: 13.1_
|
||||
|
||||
- [x] 9. 属性测试(T2-6 PBT)
|
||||
- [x] 9.1 编写属性测试:消费记录金额拆分不变量
|
||||
- **Property 1: 消费记录金额拆分不变量**
|
||||
- 测试文件:`tests/test_rns12_properties.py`
|
||||
- 生成器:`st.floats(min_value=0, max_value=1e5)` 生成 tableFee/foodAmount/coachFees
|
||||
- 验证:`abs(totalAmount - (tableFee + foodAmount + sum(fees))) < 0.01`
|
||||
- **Validates: Requirements 14.1, 4.4**
|
||||
- [x] 9.2 编写属性测试:废单排除一致性
|
||||
- **Property 2: 废单排除一致性**
|
||||
- 生成器:`st.lists(st.fixed_dictionaries({is_delete: st.integers(0,2), ...}))`
|
||||
- 验证:过滤后结果中所有 `is_delete == 0`
|
||||
- **Validates: Requirements 14.8, 4.6, 7.8, 10.7**
|
||||
- [x] 9.3 编写属性测试:助教费用拆分正确性
|
||||
- **Property 3: 助教费用拆分正确性**
|
||||
- 生成器:`st.floats` 生成 pd_money/cx_money + `st.sampled_from(["基础课","激励课"])`
|
||||
- 验证:基础课 → pd_money,激励课 → cx_money,两者之和 = 总额
|
||||
- **Validates: Requirements 14.2, 4.3, 9.2**
|
||||
- [x] 9.4 编写属性测试:favoriteCoaches 排序不变量
|
||||
- **Property 4: favoriteCoaches 排序不变量**
|
||||
- 生成器:`st.lists(st.floats(0, 1))` 生成 relationIndex 列表
|
||||
- 验证:排序后每项 ≥ 下一项
|
||||
- **Validates: Requirements 14.5, 6.1**
|
||||
- [x] 9.5 编写属性测试:historyMonths 排序与预估标记
|
||||
- **Property 5: historyMonths 排序与预估标记**
|
||||
- 生成器:`st.lists(st.dates(), min_size=1)` 生成月份列表
|
||||
- 验证:降序排列,首项 `estimated=True`,其余 `False`
|
||||
- **Validates: Requirements 14.6, 12.5**
|
||||
- [x] 9.6 编写属性测试:列表上限约束
|
||||
- **Property 6: 列表上限约束**
|
||||
- 生成器:`st.integers(0, 100)` 生成记录数
|
||||
- 验证:notes ≤ 20,topCustomers ≤ 20
|
||||
- **Validates: Requirements 3.2, 10.1, 11.5**
|
||||
- [x] 9.7 编写属性测试:月度汇总聚合正确性
|
||||
- **Property 7: 月度汇总聚合正确性**
|
||||
- 生成器:`st.lists(st.fixed_dictionaries({hours: st.floats(0,10), income: st.floats(0,1e4)}))`
|
||||
- 验证:count=len,monthHours=sum(hours)
|
||||
- **Validates: Requirements 7.6, 5.3**
|
||||
- [x] 9.8 编写属性测试:daysSinceVisit 计算正确性
|
||||
- **Property 8: daysSinceVisit 计算正确性**
|
||||
- 生成器:`st.dates(max_value=date.today())`
|
||||
- 验证:days = (today - date).days,非负整数
|
||||
- **Validates: Requirements 1.6**
|
||||
- [x] 9.9 编写属性测试:emoji 映射正确性
|
||||
- **Property 9: emoji 映射正确性**
|
||||
- 生成器:`st.floats(0, 1)` 生成 relationIndex
|
||||
- 验证:CUST-1 两级映射(≥0.7→💖,<0.7→💛);COACH-1 三级映射(≥0.7→❤️,0.3-0.7→💛,<0.3→🤍)
|
||||
- **Validates: Requirements 6.4**
|
||||
- [x] 9.10 编写属性测试:优雅降级
|
||||
- **Property 10: 优雅降级**
|
||||
- 生成器:`st.sampled_from(MODULES)` 选择失败模块
|
||||
- 验证:失败模块返回空默认值,其他模块正常,HTTP 200
|
||||
- **Validates: Requirements 1.7, 13.8**
|
||||
- [x] 9.11 编写属性测试:任务分组正确性
|
||||
- **Property 11: 任务分组正确性**
|
||||
- 生成器:`st.lists(st.fixed_dictionaries({status: st.sampled_from(STATUSES)}))`
|
||||
- 验证:active→visible,inactive→hidden,abandoned→abandoned,无交集,并集=原集合
|
||||
- **Validates: Requirements 11.1**
|
||||
- [x] 9.12 编写属性测试:数据隔离不变量
|
||||
- **Property 12: 数据隔离不变量**
|
||||
- 生成器:`st.integers(1, 1000)` 生成 customerId/coachId
|
||||
- 验证:coachTasks 每条 member_id=customerId,serviceRecords 每条 assistant_id=coachId
|
||||
- **Validates: Requirements 14.3, 14.4**
|
||||
- [x] 9.13 编写属性测试:分页与 hasMore 正确性
|
||||
- **Property 13: 分页与 hasMore 正确性**
|
||||
- 生成器:`st.integers(1,100)` total + `st.integers(1,10)` page/pageSize
|
||||
- 验证:记录数 ≤ pageSize,hasMore = (total > page*pageSize)
|
||||
- **Validates: Requirements 7.9**
|
||||
- [x] 9.14 编写属性测试:幂等性
|
||||
- **Property 14: 幂等性**
|
||||
- 生成器:`st.integers(1,12)` month + `st.integers(2020,2026)` year
|
||||
- 验证:f(x) == f(x) 对 monthCount/monthHours
|
||||
- **Validates: Requirements 14.7**
|
||||
|
||||
- [x] 10. Final checkpoint — 确保所有测试通过
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
|
||||
- [x] 11. 前端到数据库全链路测试
|
||||
- [x] 11.1 启动后端服务,使用测试库(`test_zqyy_app`)验证 CUST-1、CUST-2、COACH-1 三个端点的完整请求-响应链路
|
||||
- 使用真实 FDW 连接(`test_etl_feiqiu`)验证 SQL 查询正确性
|
||||
- 验证 JSON 响应结构与 Schema 定义一致(camelCase 序列化)
|
||||
- 验证权限校验(`require_approved()`)在真实请求中生效
|
||||
- [x] 11.2 小程序前端联调验证(如已有对应页面)
|
||||
- 确认前端页面能正确调用新增 API 并渲染数据
|
||||
- 验证空数据/降级场景下前端不崩溃
|
||||
- 如前端页面尚未开发,记录待联调清单供后续 RNS 任务使用
|
||||
|
||||
- [x] 12. 项目文档更新与落地
|
||||
- [x] 12.1 更新 `docs/contracts/openapi/backend-api.json`,补充 CUST-1、CUST-2、COACH-1 三个端点的 OpenAPI 定义
|
||||
- [x] 12.2 更新 `docs/architecture/backend-architecture.md`,补充新增的 `customer_service`、`coach_service` 模块及路由注册说明
|
||||
- [x] 12.3 更新 `docs/database/BD_Manual_biz_tables.md`,补充本次新增/引用的 `biz.coach_tasks`、`biz.notes`、`biz.ai_cache` 表的使用说明(如有新增字段或新用法)
|
||||
- [x] 12.4 更新 `docs/DOCUMENTATION-MAP.md`,确保新增文档条目已索引
|
||||
- [x] 12.5 更新 `docs/miniprogram-dev/API-contract.md`,补充 CUST-1、CUST-2、COACH-1 的接口契约(请求/响应示例)
|
||||
|
||||
- [x] 13. 数据库变更审计与 DDL 合并
|
||||
- [x] 13.1 审计本次实现中对数据库的改动(新建表、新增字段、新增索引、FDW 映射变更等)
|
||||
- 检查 `biz.coach_tasks`、`biz.notes`、`biz.ai_cache`、`public.member_retention_clue` 是否需要新建或变更
|
||||
- 检查 FDW 外部表映射是否需要更新(新增视图引用等)
|
||||
- [x] 13.2 将所有数据库变更合并到主 DDL 文件
|
||||
- 业务库变更 → `db/zqyy_app/` 对应 DDL 文件
|
||||
- FDW 变更 → `db/fdw/` 对应 DDL 文件
|
||||
- 编写日期前缀迁移脚本(如有 schema 变更)
|
||||
- [x] 13.3 更新 BD 手册记录变更
|
||||
- 业务库 → `docs/database/BD_Manual_biz_tables.md`
|
||||
- FDW → `docs/database/BD_Manual_fdw.md`(如有变更)
|
||||
- 记录变更原因、影响范围、回滚 SQL
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- 所有金额字段统一使用 `items_sum` 口径(DWD-DOC 强制规则 1),禁止 `consume_money`
|
||||
- 助教费用使用 `assistant_pd_money` + `assistant_cx_money` 拆分(DWD-DOC 强制规则 2),禁止 `service_fee`
|
||||
- 会员信息通过 `member_id` JOIN `v_dim_member`(DQ-6),余额通过 `v_dim_member_card_account`(DQ-7)
|
||||
- 废单排除统一使用 `is_delete=0`,禁止引用已废弃的 `dwd_assistant_trash_event`
|
||||
- Property tests validate universal correctness properties from design.md
|
||||
- Checkpoints ensure incremental validation
|
||||
1
.kiro/specs/rns1-infra-contract-rewrite/.config.kiro
Normal file
1
.kiro/specs/rns1-infra-contract-rewrite/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "13cfd0bc-b6d6-408e-b943-aa11fb515478", "workflowType": "requirements-first", "specType": "feature"}
|
||||
552
.kiro/specs/rns1-infra-contract-rewrite/design.md
Normal file
552
.kiro/specs/rns1-infra-contract-rewrite/design.md
Normal file
@@ -0,0 +1,552 @@
|
||||
# 技术设计文档 — RNS1.0:基础设施与契约重写
|
||||
|
||||
## 概述
|
||||
|
||||
RNS1.0 是 NS1 小程序后端 API 补全项目的基础设施层,阻塞所有后续子 spec(RNS1.1-1.4)。本设计覆盖 6 个任务:
|
||||
|
||||
1. **全局响应包装中间件**(T0-1):ASGI 中间件 + FastAPI 异常处理器,统一 `{ code: 0, data }` 格式
|
||||
2. **Pydantic camelCase 统一**(T0-2):基类 `CamelModel` 配置 `alias_generator=to_camel`
|
||||
3. **路由路径修正**(T0-3):`/cancel-abandon` → `/restore`
|
||||
4. **前端 request() 解包**(T0-4):`.data` 自动提取 + 错误码处理
|
||||
5. **API 契约完全重写**(T0-5):8 个接口的响应定义重写为独立文档
|
||||
6. **前端跨页面参数修复**(T0-6):8 个页面间的参数传递统一用唯一 ID
|
||||
|
||||
### 设计原则
|
||||
|
||||
- **最小侵入**:全局中间件对现有 16 个端点透明生效,无需逐个修改路由函数
|
||||
- **契约驱动**:API 契约文档是后续子 spec 实现的唯一基准,前后端共同遵守
|
||||
- **DWD-DOC 强制规则**:所有金额字段使用 `items_sum` 口径,助教费用使用 `assistant_pd_money` + `assistant_cx_money` 拆分
|
||||
|
||||
## 架构
|
||||
|
||||
### 整体架构
|
||||
|
||||
RNS1.0 的改动集中在 3 个层面:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "微信小程序 (apps/miniprogram/)"
|
||||
A[services/api.ts] --> B[utils/request.ts]
|
||||
C[各页面 .ts] --> A
|
||||
end
|
||||
|
||||
subgraph "FastAPI 后端 (apps/backend/app/)"
|
||||
D[ResponseWrapperMiddleware<br/>ASGI 中间件] --> E[ExceptionHandler<br/>FastAPI 异常处理器]
|
||||
E --> F[routers/xcx_*.py<br/>路由层]
|
||||
F --> G[services/*.py<br/>业务逻辑层]
|
||||
G --> H[database.py<br/>数据库连接]
|
||||
end
|
||||
|
||||
subgraph "数据库"
|
||||
I[(zqyy_app<br/>业务库)]
|
||||
J[(etl_feiqiu<br/>ETL 库 via FDW)]
|
||||
end
|
||||
|
||||
B -->|HTTP JSON| D
|
||||
H --> I
|
||||
H --> J
|
||||
|
||||
style D fill:#f9f,stroke:#333
|
||||
style E fill:#f9f,stroke:#333
|
||||
style B fill:#bbf,stroke:#333
|
||||
```
|
||||
|
||||
### 请求-响应流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MP as 小程序 request()
|
||||
participant MW as ResponseWrapperMiddleware
|
||||
participant EH as ExceptionHandler
|
||||
participant R as Router 端点
|
||||
participant S as Service 层
|
||||
|
||||
MP->>MW: HTTP Request
|
||||
MW->>R: 透传请求
|
||||
R->>S: 调用业务逻辑
|
||||
|
||||
alt 成功
|
||||
S-->>R: 返回数据
|
||||
R-->>MW: JSONResponse(data)
|
||||
MW-->>MP: { code: 0, data: ... }
|
||||
end
|
||||
|
||||
alt HTTPException
|
||||
S-->>EH: raise HTTPException(status, detail)
|
||||
EH-->>MW: JSONResponse({ code, message })
|
||||
MW-->>MP: { code: status_code, message: detail }
|
||||
end
|
||||
|
||||
alt 未捕获异常
|
||||
S-->>EH: raise Exception
|
||||
EH-->>MW: JSONResponse({ code: 500, message })
|
||||
Note over EH: 完整堆栈写入服务端日志
|
||||
MW-->>MP: { code: 500, message: "Internal Server Error" }
|
||||
end
|
||||
|
||||
alt SSE 流式
|
||||
S-->>R: StreamingResponse(text/event-stream)
|
||||
R-->>MW: StreamingResponse
|
||||
MW-->>MP: 直接透传,不包装
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 组件 1:ResponseWrapperMiddleware(ASGI 中间件)
|
||||
|
||||
**位置**:`apps/backend/app/middleware/response_wrapper.py`
|
||||
|
||||
**职责**:拦截所有 HTTP 响应,对 JSON 成功响应自动包装为 `{ code: 0, data }` 格式。
|
||||
|
||||
**选型理由**:选择 ASGI 中间件而非 FastAPI 中间件(`BaseHTTPMiddleware`),原因:
|
||||
- `BaseHTTPMiddleware` 会将 `StreamingResponse` 缓冲为完整响应体,破坏 SSE 流式传输
|
||||
- ASGI 中间件可在 `http.response.start` 阶段检查 `content-type`,对 SSE 直接透传
|
||||
- 性能更优,无额外的请求体/响应体缓冲开销
|
||||
|
||||
**接口定义**:
|
||||
|
||||
```python
|
||||
class ResponseWrapperMiddleware:
|
||||
"""ASGI 中间件:全局响应包装。"""
|
||||
|
||||
def __init__(self, app: ASGIApp):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
||||
# 仅处理 HTTP 请求
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
# 拦截响应头,检查 content-type
|
||||
# - text/event-stream → 透传
|
||||
# - 非 application/json → 透传
|
||||
# - application/json + 2xx → 包装为 { code: 0, data: <原始body> }
|
||||
# - application/json + 非 2xx → 透传(已由 ExceptionHandler 格式化)
|
||||
...
|
||||
```
|
||||
|
||||
**跳过条件**:
|
||||
1. `content-type` 为 `text/event-stream`(SSE 端点)
|
||||
2. `content-type` 不包含 `application/json`(文件下载等)
|
||||
3. HTTP 状态码非 2xx(错误响应已由 ExceptionHandler 格式化)
|
||||
|
||||
### 组件 2:ExceptionHandler(FastAPI 异常处理器)
|
||||
|
||||
**位置**:`apps/backend/app/middleware/response_wrapper.py`(与中间件同文件)
|
||||
|
||||
**职责**:捕获 `HTTPException` 和未处理异常,统一格式化为 `{ code, message }`。
|
||||
|
||||
**接口定义**:
|
||||
|
||||
```python
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
"""HTTPException → { code: <status_code>, message: <detail> }"""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"code": exc.status_code, "message": exc.detail},
|
||||
)
|
||||
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""未捕获异常 → { code: 500, message: "Internal Server Error" }
|
||||
完整堆栈写入服务端日志。"""
|
||||
logger.exception("未捕获异常: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"code": 500, "message": "Internal Server Error"},
|
||||
)
|
||||
```
|
||||
|
||||
**注册方式**(`main.py`):
|
||||
|
||||
```python
|
||||
from app.middleware.response_wrapper import (
|
||||
ResponseWrapperMiddleware,
|
||||
http_exception_handler,
|
||||
unhandled_exception_handler,
|
||||
)
|
||||
|
||||
# 异常处理器(在路由注册之后)
|
||||
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||
|
||||
# ASGI 中间件(在 CORS 之后添加,注意顺序:后添加的先执行)
|
||||
app.add_middleware(ResponseWrapperMiddleware)
|
||||
```
|
||||
|
||||
### 组件 3:CamelModel(Pydantic 基类)
|
||||
|
||||
**位置**:`apps/backend/app/schemas/base.py`
|
||||
|
||||
**职责**:所有 Pydantic 响应 schema 的基类,统一配置 camelCase 输出。
|
||||
|
||||
**接口定义**:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
class CamelModel(BaseModel):
|
||||
"""所有小程序 API 响应 schema 的基类。
|
||||
|
||||
- alias_generator=to_camel:JSON 输出字段名自动转 camelCase
|
||||
- populate_by_name=True:同时接受 snake_case 和 camelCase 输入
|
||||
- from_attributes=True:支持从 ORM 对象/dict 构造
|
||||
"""
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
populate_by_name=True,
|
||||
from_attributes=True,
|
||||
)
|
||||
```
|
||||
|
||||
**迁移策略**:
|
||||
- 所有现有 schema(`xcx_tasks.py`、`xcx_auth.py`、`xcx_notes.py`)的 `BaseModel` 替换为 `CamelModel`
|
||||
- 路由函数中 `response_model` 不变,Pydantic 自动使用 alias 序列化
|
||||
- 新增 schema 统一继承 `CamelModel`
|
||||
|
||||
### 组件 4:前端 request() 解包
|
||||
|
||||
**位置**:`apps/miniprogram/miniprogram/utils/request.ts`
|
||||
|
||||
**职责**:从全局包装中自动提取 `.data` 字段。
|
||||
|
||||
**改动逻辑**:
|
||||
|
||||
```typescript
|
||||
// request() 函数返回值处理(伪代码)
|
||||
const res = await wx.request({ ... })
|
||||
|
||||
// 检查是否为标准包装格式
|
||||
if (res.data && typeof res.data === 'object' && 'code' in res.data) {
|
||||
if (res.data.code === 0) {
|
||||
return res.data.data // 成功:返回业务数据
|
||||
} else {
|
||||
throw { code: res.data.code, message: res.data.message } // 错误:抛出
|
||||
}
|
||||
}
|
||||
// 非标准格式(SSE 等):直接返回
|
||||
return res.data
|
||||
```
|
||||
|
||||
### 组件 5:路由路径修正
|
||||
|
||||
**位置**:`apps/backend/app/routers/xcx_tasks.py`
|
||||
|
||||
**改动**:
|
||||
|
||||
```python
|
||||
# 修改前
|
||||
@router.post("/{task_id}/cancel-abandon")
|
||||
async def cancel_abandon(task_id: int, ...):
|
||||
|
||||
# 修改后
|
||||
@router.post("/{task_id}/restore")
|
||||
async def restore_task(task_id: int, ...):
|
||||
```
|
||||
|
||||
- 函数名从 `cancel_abandon` 改为 `restore_task`(语义更清晰)
|
||||
- 业务逻辑不变,仍调用 `task_manager.cancel_abandon()`
|
||||
- 不保留旧路径兼容映射
|
||||
|
||||
### 组件 6:API 契约文档
|
||||
|
||||
**位置**:`docs/miniprogram-dev/API-contract.md`(原地重写)
|
||||
|
||||
**组织结构**:按接口分节,每个接口包含:
|
||||
- 端点路径 + HTTP 方法
|
||||
- 请求参数(Query / Path / Body)
|
||||
- 响应结构(TypeScript 类型定义 + 字段说明表)
|
||||
- 数据源标注(FDW 表名或业务表名)
|
||||
- DWD-DOC 强制规则标注(涉及金额/会员字段时)
|
||||
|
||||
重写范围:BOARD-1/2/3、CUST-1、COACH-1、PERF-1、TASK-1 performance、CHAT-1/2(共 8 个接口)。
|
||||
|
||||
### 组件 7:前端跨页面参数修复
|
||||
|
||||
**涉及文件**:
|
||||
|
||||
| 页面文件 | 修改内容 |
|
||||
|---------|---------|
|
||||
| `pages/task-detail/task-detail.ts` | 跳转 chat/customer-service-records 时传 `customerId` 而非 `detail.id` |
|
||||
| `pages/customer-detail/customer-detail.ts` | 跳转 chat/customer-service-records 时传 `customerId`;`loadDetail()` 从 `onLoad(options)` 获取 ID |
|
||||
| `pages/coach-detail/coach-detail.ts` | 任务项跳转 customer-detail 时传 `id={customerId}` 而非 `name` |
|
||||
| `pages/performance/performance.ts` | 跳转 task-detail 时传 `id={taskId}` 而非 `customerName` |
|
||||
| `pages/chat/chat.ts` | 支持 `customerId`/`historyId`/`coachId` 三种入口参数路由 |
|
||||
| `app.ts` | 登录后将 `role`/`storeName`/`coachLevel`/`avatar` 存入 `globalData.authUser` |
|
||||
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 全局响应包装格式
|
||||
|
||||
```typescript
|
||||
// 成功响应
|
||||
interface SuccessResponse<T> {
|
||||
code: 0
|
||||
data: T
|
||||
}
|
||||
|
||||
// 错误响应
|
||||
interface ErrorResponse {
|
||||
code: number // HTTP 状态码(400/401/403/404/500 等)
|
||||
message: string // 错误描述
|
||||
}
|
||||
```
|
||||
|
||||
### CamelModel 基类影响的现有 Schema
|
||||
|
||||
以下现有 schema 需从 `BaseModel` 迁移到 `CamelModel`:
|
||||
|
||||
| Schema 文件 | 类名 | 影响字段(snake_case → camelCase) |
|
||||
|------------|------|----------------------------------|
|
||||
| `schemas/xcx_tasks.py` | `TaskListItem` | `task_type` → `taskType`、`priority_score` → `priorityScore`、`is_pinned` → `isPinned`、`expires_at` → `expiresAt`、`created_at` → `createdAt`、`member_id` → `memberId`、`member_name` → `memberName`、`member_phone` → `memberPhone`、`rs_score` → `rsScore`、`heart_icon` → `heartIcon`、`abandon_reason` → `abandonReason` |
|
||||
| `schemas/xcx_tasks.py` | `AbandonRequest` | `reason`(单字段,无变化) |
|
||||
| `schemas/xcx_auth.py` | 所有 Auth schema | 需逐个检查字段名 |
|
||||
| `schemas/xcx_notes.py` | 所有 Notes schema | 需逐个检查字段名 |
|
||||
|
||||
### 契约重写涉及的新增数据结构(概要)
|
||||
|
||||
以下为 T0-5 契约重写中定义的核心数据结构,完整定义在 API 契约文档中:
|
||||
|
||||
#### BOARD-3 财务看板(6 板块嵌套结构)
|
||||
|
||||
```typescript
|
||||
interface BoardFinanceResponse {
|
||||
overview: OverviewSection // 经营一览:8 指标 + 8 环比
|
||||
recharge: RechargeSection | null // 预收资产:储值卡 + 赠送卡矩阵(area≠all 时为 null)
|
||||
revenue: RevenueSection // 应计收入:结构表 + 明细
|
||||
cashflow: CashflowSection // 现金流入:消费收款 + 充值收款
|
||||
expense: ExpenseSection // 现金流出:4 子分组
|
||||
coachAnalysis: CoachAnalysisSection // 助教分析:基础课 + 激励课
|
||||
}
|
||||
|
||||
// 环比字段通用模式
|
||||
interface CompareField {
|
||||
value: number
|
||||
compare: string // 如 "+12.5%"
|
||||
isDown: boolean
|
||||
isFlat: boolean
|
||||
}
|
||||
```
|
||||
|
||||
#### BOARD-1 助教看板(基础字段 + 4 维度专属字段)
|
||||
|
||||
```typescript
|
||||
interface CoachBoardItem {
|
||||
// 基础字段(所有维度共享)
|
||||
id: string
|
||||
name: string
|
||||
initial: string
|
||||
avatarGradient: string
|
||||
level: string
|
||||
skills: Array<{ text: string; cls: string }>
|
||||
topCustomers: string[]
|
||||
|
||||
// perf 维度专属
|
||||
perfHours?: number
|
||||
perfHoursBefore?: number
|
||||
perfGap?: string
|
||||
perfReached?: boolean
|
||||
|
||||
// salary 维度专属
|
||||
salary?: number
|
||||
salaryPerfHours?: number
|
||||
salaryPerfBefore?: number
|
||||
|
||||
// sv 维度专属
|
||||
svAmount?: number
|
||||
svCustomerCount?: number
|
||||
svConsume?: number
|
||||
|
||||
// task 维度专属
|
||||
taskRecall?: number
|
||||
taskCallback?: number
|
||||
}
|
||||
```
|
||||
|
||||
#### BOARD-2 客户看板(基础字段 + 8 维度专属字段)
|
||||
|
||||
```typescript
|
||||
interface CustomerBoardItem {
|
||||
// 基础字段
|
||||
id: string
|
||||
name: string
|
||||
initial: string
|
||||
avatarCls: string
|
||||
assistants: Array<{
|
||||
name: string; cls: string; heartScore: number
|
||||
badge?: string; badgeCls?: string
|
||||
}>
|
||||
|
||||
// 各维度专属字段按 dimension 参数动态返回
|
||||
// recall / potential / balance / recharge / recent / spend60 / freq60 / loyal
|
||||
[key: string]: any
|
||||
}
|
||||
```
|
||||
|
||||
#### PERF-1 绩效概览(DateGroup 分组结构)
|
||||
|
||||
```typescript
|
||||
interface DateGroup {
|
||||
date: string // 日期标签,如 "3月15日 周六"
|
||||
totalHours: number // 当日总工时
|
||||
totalIncome: number // 当日总收入
|
||||
records: Array<{
|
||||
customerName: string
|
||||
timeRange: string
|
||||
hours: number
|
||||
courseType: string
|
||||
courseTypeClass: string // basic / vip / tip
|
||||
location: string
|
||||
income: number
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
### DWD-DOC 强制规则在数据模型中的体现
|
||||
|
||||
| 规则 | 影响范围 | 实施方式 |
|
||||
|------|---------|---------|
|
||||
| `items_sum` 口径 | BOARD-3 所有金额、CUST-1 消费金额、PERF-1 收入 | 契约文档标注,后端 SQL 使用 `items_sum` 字段 |
|
||||
| 助教费用拆分 | BOARD-3 助教分析、CUST-1 消费记录 coaches | 使用 `assistant_pd_money` + `assistant_cx_money` |
|
||||
| 会员信息 JOIN | CUST-1、BOARD-2、TASK-1 | 通过 `member_id` JOIN `dim_member`,禁用 `member_phone` |
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性(Property)是一个在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
RNS1.0 的核心可测试逻辑集中在全局响应包装中间件(T0-1)、camelCase 转换(T0-2)和前端解包(T0-4)。API 契约重写(T0-5)和前端参数修复(T0-6)主要是文档和具体代码修改,通过示例测试覆盖。
|
||||
|
||||
### Property 1: 响应包装-解包 Round Trip
|
||||
|
||||
*For any* 合法的 JSON 可序列化 Python 对象 `data`,经 `ResponseWrapperMiddleware` 包装为 `{ "code": 0, "data": data }` 后,再经前端 `request()` 的 `.data` 解包,应该得到与原始 `data` 结构等价的对象。
|
||||
|
||||
**Validates: Requirements 1.1, 4.1**
|
||||
|
||||
### Property 2: 异常响应保持 code 和 message
|
||||
|
||||
*For any* HTTP 状态码 `status`(400-599 范围)和任意非空字符串 `detail`,当路由抛出 `HTTPException(status_code=status, detail=detail)` 时,`ExceptionHandler` 的输出 JSON 应满足 `output.code == status` 且 `output.message == detail`。对于未捕获异常(edge case),输出应固定为 `code=500, message="Internal Server Error"`。
|
||||
|
||||
**Validates: Requirements 1.2, 1.3**
|
||||
|
||||
### Property 3: 非 JSON 响应透传
|
||||
|
||||
*For any* HTTP 响应,若其 `content-type` 不包含 `application/json`(包括 `text/event-stream`、`application/octet-stream`、`text/html` 等),`ResponseWrapperMiddleware` 应不修改响应体,输出与输入完全相同。
|
||||
|
||||
**Validates: Requirements 1.5, 1.6**
|
||||
|
||||
### Property 4: camelCase 转换 Round Trip
|
||||
|
||||
*For any* 继承 `CamelModel` 的 Pydantic schema 实例,将其序列化为 JSON(使用 `model_dump(by_alias=True)`)得到 camelCase 字段名的 dict,再用该 dict 反序列化回同一 schema 类(通过 `populate_by_name=True`),应得到与原始实例等价的对象。
|
||||
|
||||
**Validates: Requirements 2.2, 2.4**
|
||||
|
||||
### Property 5: 错误码解包抛出
|
||||
|
||||
*For any* 响应对象 `{ code: n, message: m }`,其中 `n` 为非零整数,前端 `request()` 解包时应抛出包含 `code=n` 和 `message=m` 的错误对象,不返回任何业务数据。
|
||||
|
||||
**Validates: Requirements 4.2**
|
||||
|
||||
### Property 6: 非标准格式响应透传
|
||||
|
||||
*For any* 响应对象,若其不包含 `code` 字段(即非全局包装格式),前端 `request()` 应直接返回原始响应体,不做任何解包或错误处理。
|
||||
|
||||
**Validates: Requirements 4.3**
|
||||
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 后端错误处理层次
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[路由层抛出 HTTPException] --> B[http_exception_handler]
|
||||
C[Service 层未捕获异常] --> D[unhandled_exception_handler]
|
||||
B --> E["{ code: status_code, message: detail }"]
|
||||
D --> F["{ code: 500, message: 'Internal Server Error' }"]
|
||||
D --> G[logger.exception 写入完整堆栈]
|
||||
```
|
||||
|
||||
| 错误类型 | 处理方式 | 响应格式 | 日志 |
|
||||
|---------|---------|---------|------|
|
||||
| `HTTPException(400, "参数错误")` | `http_exception_handler` | `{ code: 400, message: "参数错误" }` | 无(业务预期错误) |
|
||||
| `HTTPException(401, "未认证")` | `http_exception_handler` | `{ code: 401, message: "未认证" }` | 无 |
|
||||
| `HTTPException(403, "权限不足")` | `http_exception_handler` | `{ code: 403, message: "权限不足" }` | WARNING(已有,permission.py) |
|
||||
| `HTTPException(404, "资源不存在")` | `http_exception_handler` | `{ code: 404, message: "资源不存在" }` | 无 |
|
||||
| `psycopg2.OperationalError` | `unhandled_exception_handler` | `{ code: 500, message: "Internal Server Error" }` | ERROR + 完整堆栈 |
|
||||
| `ValueError` / `TypeError` 等 | `unhandled_exception_handler` | `{ code: 500, message: "Internal Server Error" }` | ERROR + 完整堆栈 |
|
||||
|
||||
### 前端错误处理
|
||||
|
||||
| 场景 | request() 行为 | 调用方处理 |
|
||||
|------|---------------|-----------|
|
||||
| `code: 0` | 返回 `data` 字段 | 正常渲染 |
|
||||
| `code: 401` | 抛出 `{ code: 401, message }` | 跳转登录页 |
|
||||
| `code: 403` | 抛出 `{ code: 403, message }` | 显示"权限不足"提示 |
|
||||
| `code: 400/404/500` | 抛出 `{ code, message }` | 显示错误提示 toast |
|
||||
| 无 `code` 字段 | 直接返回原始响应 | SSE 等特殊场景处理 |
|
||||
| 网络超时/断连 | wx.request 自身 fail 回调 | 显示网络错误提示 |
|
||||
|
||||
### 中间件错误处理
|
||||
|
||||
`ResponseWrapperMiddleware` 自身的异常处理:
|
||||
- 如果中间件在包装过程中出错(如 JSON 解析失败),应透传原始响应,不阻塞请求
|
||||
- 中间件不应吞掉任何异常,仅做格式转换
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 双轨测试方法
|
||||
|
||||
RNS1.0 采用属性测试(Property-Based Testing)+ 单元测试(Unit Testing)双轨并行:
|
||||
|
||||
- **属性测试**:验证全局响应包装、camelCase 转换、前端解包等通用规则在所有输入上的正确性
|
||||
- **单元测试**:验证具体的路由路径修改、参数传递修复、边界条件等
|
||||
|
||||
### 属性测试配置
|
||||
|
||||
- **测试库**:[Hypothesis](https://hypothesis.readthedocs.io/)(Python,已在项目中使用,见 `.hypothesis/` 目录)
|
||||
- **测试位置**:`tests/` 目录(Monorepo 级属性测试)
|
||||
- **最小迭代次数**:每个属性测试 100 次(`@settings(max_examples=100)`)
|
||||
- **标签格式**:每个测试函数的 docstring 中标注 `Feature: rns1-infra-contract-rewrite, Property {N}: {property_text}`
|
||||
|
||||
### 属性测试清单
|
||||
|
||||
| Property | 测试函数 | 生成器 | 验证逻辑 |
|
||||
|----------|---------|--------|---------|
|
||||
| P1: 响应包装-解包 Round Trip | `test_response_wrap_unwrap_roundtrip` | `st.from_type(dict\|list\|str\|int\|float\|bool\|None)` 生成随机 JSON 可序列化数据 | 包装后 JSON 包含 `code=0` 和 `data`;解包 `data` 等于原始输入 |
|
||||
| P2: 异常响应保持 code 和 message | `test_exception_handler_preserves_code_message` | `st.integers(min_value=400, max_value=599)` × `st.text(min_size=1)` | 输出 JSON 的 `code` 等于输入状态码,`message` 等于输入 detail |
|
||||
| P3: 非 JSON 响应透传 | `test_non_json_response_passthrough` | `st.sampled_from(["text/event-stream", "application/octet-stream", "text/html", ...])` × `st.binary()` | 中间件输出的响应体与输入完全相同 |
|
||||
| P4: camelCase 转换 Round Trip | `test_camel_case_roundtrip` | `st.fixed_dictionaries` 生成随机 snake_case 字段名和值 | `model_dump(by_alias=True)` → `Model(**camel_dict)` → 等于原始实例 |
|
||||
| P5: 错误码解包抛出 | `test_error_code_unpacker_throws` | `st.integers().filter(lambda x: x != 0)` × `st.text()` | 解包函数对 `{ code: n, message: m }` 抛出异常,异常包含 code 和 message |
|
||||
| P6: 非标准格式透传 | `test_non_standard_response_passthrough` | `st.dictionaries(st.text(), st.text()).filter(lambda d: "code" not in d)` | 解包函数直接返回原始 dict |
|
||||
|
||||
### 单元测试清单
|
||||
|
||||
| 测试目标 | 测试文件 | 关键用例 |
|
||||
|---------|---------|---------|
|
||||
| 路由路径修正 | `tests/unit/test_xcx_tasks_route.py` | `/restore` 返回 200;`/cancel-abandon` 返回 404 |
|
||||
| CamelModel 基类 | `tests/unit/test_camel_model.py` | `TaskListItem` 序列化输出 camelCase;反序列化接受两种格式 |
|
||||
| 中间件跳过逻辑 | `tests/unit/test_response_wrapper.py` | SSE 端点不包装;health 端点包装;非 2xx 不二次包装 |
|
||||
| 前端参数传递 | 手动联调验证 | 8 个跳转场景逐一验证 URL 参数正确 |
|
||||
|
||||
### 测试执行命令
|
||||
|
||||
```bash
|
||||
# 属性测试(Hypothesis)
|
||||
cd C:\NeoZQYY && pytest tests/ -v -k "rns1"
|
||||
|
||||
# 单元测试
|
||||
cd apps/backend && pytest tests/unit/ -v -k "response_wrapper or camel_model or xcx_tasks_route"
|
||||
```
|
||||
|
||||
### 契约文档验证
|
||||
|
||||
API 契约重写(T0-5)的验证方式:
|
||||
- 人工 review:对照前端 mock 数据结构,逐字段比对
|
||||
- 后续子 spec 实现时,后端响应必须通过 Pydantic schema 验证(schema 从契约定义生成)
|
||||
- 前端联调时,逐页面验证数据渲染正确
|
||||
|
||||
239
.kiro/specs/rns1-infra-contract-rewrite/requirements.md
Normal file
239
.kiro/specs/rns1-infra-contract-rewrite/requirements.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# 需求文档 — RNS1.0:基础设施与契约重写
|
||||
|
||||
## 简介
|
||||
|
||||
RNS1.0 是 NS1 小程序后端 API 补全项目的第一个子 spec,负责建立全局基础设施(响应包装、camelCase 转换)、修正路由路径、适配前端解包逻辑、完全重写 API 契约响应定义、以及修复前端跨页面参数传递问题。本 spec 阻塞所有后续子 spec(RNS1.1-1.4),必须最先完成。
|
||||
|
||||
### 来源文档
|
||||
|
||||
- `docs/prd/Neo_Specs/RNS1-split-plan.md` — 拆分计划主文档
|
||||
- `docs/prd/Neo_Specs/NS1-xcx-backend-api.md` — NS1 原始 spec(含八½前置审查决策 R1-R8)
|
||||
- `docs/prd/Neo_Specs/storyboard-walkthrough-assistant-view.md` — 助教视角走查报告(51 Gap)
|
||||
- `docs/prd/Neo_Specs/miniprogram-storyboard-walkthrough-gaps.md` — 管理层视角走查报告(31 Gap)
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Response_Wrapper**:全局响应包装中间件,将 FastAPI 路由返回值统一封装为 `{ code: 0, data: ... }` 格式
|
||||
- **Exception_Handler**:全局异常处理器,将 HTTPException 和未捕获异常统一封装为 `{ code: <status_code>, message: <detail> }` 格式
|
||||
- **CamelCase_Converter**:Pydantic schema 的 `alias_generator=to_camel` 配置,使 JSON 响应字段名从 snake_case 转为 camelCase
|
||||
- **API_Contract**:`docs/miniprogram-dev/API-contract.md`,定义前后端接口的请求/响应格式基准文档
|
||||
- **Frontend_Unpacker**:前端 `services/api.ts` 中 `request()` 工具函数的 `.data` 解包逻辑,从全局包装中提取业务数据
|
||||
- **Backend**:FastAPI 后端应用,位于 `apps/backend/`
|
||||
- **Miniprogram**:微信小程序前端应用,位于 `apps/miniprogram/`
|
||||
- **FDW**:PostgreSQL Foreign Data Wrapper,用于从业务库 `zqyy_app` 访问 ETL 库 `etl_feiqiu` 的数据
|
||||
- **DateGroup**:按日期分组的数据结构,包含日期标签、当日汇总、记录列表
|
||||
- **DWD-DOC**:`docs/reports/DWD-DOC/` 标杆文档,金额口径和字段语义的权威参考
|
||||
- **items_sum**:DWD-DOC 强制使用的消费金额口径,= `table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money`
|
||||
- **环比**:月环比,当期值与上一个相同时间周期的对比百分比
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:全局响应包装中间件(T0-1)
|
||||
|
||||
**用户故事:** 作为后端开发者,我希望所有 API 响应自动包装为统一格式,以便前端可以用一致的方式解析成功和错误响应。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Response_Wrapper SHALL 将所有成功响应(HTTP 2xx)封装为 `{ "code": 0, "data": <原始响应体> }` 格式
|
||||
2. THE Exception_Handler SHALL 将 HTTPException 封装为 `{ "code": <HTTP状态码>, "message": <错误详情> }` 格式
|
||||
3. THE Exception_Handler SHALL 将未捕获的服务端异常封装为 `{ "code": 500, "message": "Internal Server Error" }` 格式,同时将完整异常堆栈写入服务端日志
|
||||
4. WHEN 已有接口(Auth、Tasks、Notes、AI Chat 共 16 个端点)返回响应时,THE Response_Wrapper SHALL 对这些接口同样生效,保持向后兼容
|
||||
5. WHEN 响应内容类型为 `text/event-stream`(SSE 流式端点)时,THE Response_Wrapper SHALL 跳过包装,直接透传原始响应
|
||||
6. WHEN 响应内容类型为非 JSON 格式(如文件下载)时,THE Response_Wrapper SHALL 跳过包装,直接透传原始响应
|
||||
|
||||
|
||||
### 需求 2:Pydantic Schema 统一 camelCase 输出(T0-2)
|
||||
|
||||
**用户故事:** 作为前端开发者,我希望后端 API 响应的 JSON 字段名统一为 camelCase 格式,以便前端无需手动转换 snake_case 字段。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE CamelCase_Converter SHALL 为所有 Pydantic 响应 schema 配置 `alias_generator=to_camel` 和 `populate_by_name=True`
|
||||
2. WHEN Backend 返回 JSON 响应时,THE CamelCase_Converter SHALL 将所有字段名从 snake_case 转换为 camelCase(例如 `user_id` → `userId`,`store_name` → `storeName`)
|
||||
3. THE CamelCase_Converter SHALL 同时应用于所有现有 schema(Auth、Tasks、Notes、AI Chat 模块)和所有新增 schema
|
||||
4. WHEN Backend 接收请求体时,THE CamelCase_Converter SHALL 同时接受 camelCase 和 snake_case 格式的字段名(通过 `populate_by_name=True` 实现)
|
||||
|
||||
### 需求 3:后端路由路径修正(T0-3)
|
||||
|
||||
**用户故事:** 作为前端开发者,我希望后端任务恢复端点的路径与 API 契约和前端调用一致,以便联调时不会因路径不匹配而失败。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Backend SHALL 将 `POST /api/xcx/tasks/{taskId}/cancel-abandon` 端点的路径修改为 `POST /api/xcx/tasks/{taskId}/restore`
|
||||
2. WHEN 前端调用 `POST /api/xcx/tasks/{taskId}/restore` 时,THE Backend SHALL 执行与原 `/cancel-abandon` 端点相同的业务逻辑(将任务状态从 `abandoned` 恢复为 `pending`)
|
||||
3. THE Backend SHALL 移除原 `/cancel-abandon` 路径,不保留旧路径的兼容映射
|
||||
|
||||
### 需求 4:前端请求工具函数适配全局包装(T0-4)
|
||||
|
||||
**用户故事:** 作为前端开发者,我希望 `request()` 工具函数自动从全局包装中提取业务数据,以便各页面调用 API 后无需手动解包。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Frontend_Unpacker SHALL 在 `services/api.ts` 的 `request()` 函数中,对成功响应自动提取 `.data` 字段并返回给调用方
|
||||
2. WHEN 响应的 `code` 字段不为 0 时,THE Frontend_Unpacker SHALL 抛出包含 `code` 和 `message` 的错误对象,供调用方的 catch 块处理
|
||||
3. WHEN 响应不包含 `code` 字段(非标准格式,如 SSE 流式响应)时,THE Frontend_Unpacker SHALL 直接返回原始响应体,不做解包处理
|
||||
4. THE Frontend_Unpacker SHALL 确保所有现有 API 调用(Auth、Tasks、Notes、AI Chat)在解包后行为不变
|
||||
|
||||
|
||||
### 需求 5:API 契约完全重写(T0-5)
|
||||
|
||||
**用户故事:** 作为前后端开发者,我希望 API 契约文档准确反映前端实际需要的响应结构,以便后续子 spec(RNS1.1-1.4)的后端实现有明确的、与前端一致的接口定义作为基准。
|
||||
|
||||
#### 5.1 BOARD-3 财务看板契约重写
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE API_Contract SHALL 将 BOARD-3 响应从扁平 `metrics` 数组重写为 6 个独立板块的嵌套结构:`overview`(经营一览)、`recharge`(预收资产)、`revenue`(应计收入确认)、`cashflow`(现金流入)、`expense`(现金流出)、`coachAnalysis`(助教分析)
|
||||
2. THE API_Contract SHALL 为 `overview` 板块定义 8 个指标字段(`occurrence`/`discount`/`discountRate`/`confirmedRevenue`/`cashIn`/`cashOut`/`cashBalance`/`balanceRate`),每个指标附带对应的环比字段(`xxxCompare: string`)和方向标记(`isDown: boolean`、`isFlat: boolean`)
|
||||
3. THE API_Contract SHALL 为 `recharge` 板块定义储值卡 5 个指标(`actualIncome`/`firstCharge`/`renewCharge`/`consumed`/`cardBalance`)及各自环比字段,以及赠送卡 3×4 矩阵(行:新增/消费/余额,列:合计/酒水卡/台费卡/抵用券),每个单元格含值和环比字段,以及全类别会员卡余额合计(`allCardBalance`)及环比
|
||||
4. THE API_Contract SHALL 为 `revenue` 板块定义收入结构表(`structureRows`:9 行含子行标记 `isSub`,每行含 `name`/`desc`/`amount`/`discount`/`booked`/`bookedCompare`)、正价明细(`priceItems`:4 项)、优惠明细(`discountItems`:4 项)、渠道明细(`channelItems`:3 项),以及确认收入合计及环比
|
||||
5. THE API_Contract SHALL 为 `cashflow` 板块定义消费收款(`consumeItems`:3 项)、充值收款(`rechargeItems`:1 项)、合计及环比,每项含 `name`/`desc`/`value`/`compare`/`isDown`
|
||||
6. THE API_Contract SHALL 为 `expense` 板块定义 4 个子分组:经营支出(`operationItems`:3 项)、固定支出(`fixedItems`:4 项)、助教分成(`coachItems`:4 项)、平台服务费(`platformItems`:3 项),以及合计及环比
|
||||
7. THE API_Contract SHALL 为 `coachAnalysis` 板块定义基础课(`basic`)和激励课(`incentive`)两个子表,每个子表含汇总行(`totalPay`/`totalShare`/`avgHourly` 及各自环比)和按等级分行的数组(`rows`:初级/中级/高级/星级,每行含 `level`/`pay`/`payCompare`/`share`/`shareCompare`/`hourly`/`hourlyCompare` 及 `payDown`/`shareDown`/`hourlyFlat` 布尔标记)
|
||||
8. THE API_Contract SHALL 为 BOARD-3 定义请求参数:`time`(8 种时间范围枚举:`month`/`lastMonth`/`week`/`lastWeek`/`quarter3`/`quarter`/`lastQuarter`/`half6`)、`area`(7 种区域枚举:`all`/`hall`/`hallA`/`hallB`/`hallC`/`mahjong`/`teamBuilding`)、`compare`(`0`/`1`,控制是否返回环比数据)
|
||||
9. WHEN `area` 不为 `all` 时,THE API_Contract SHALL 标注 `recharge`(预收资产)板块不返回数据(储值卡数据不按区域拆分)
|
||||
10. THE API_Contract SHALL 标注所有金额字段使用 `items_sum` 口径(DWD-DOC 强制规则 1),助教费用使用 `assistant_pd_money` + `assistant_cx_money` 拆分(DWD-DOC 强制规则 2)
|
||||
|
||||
#### 5.2 BOARD-1 助教看板契约重写
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE API_Contract SHALL 将 BOARD-1 响应从 10 个通用字段扩展为包含基础字段和 4 维度专属字段的结构
|
||||
2. THE API_Contract SHALL 为每个助教 item 定义基础字段:`id`/`name`/`initial`/`avatarGradient`/`level`/`skills`/`topCustomers`,其中 `skills` 类型为 `Array<{ text: string, cls: string }>`(含 emoji 和样式类),`topCustomers` 类型为 `string[]`(含 P6 AC3 四级 emoji 前缀,如 `'💖 王先生'`/`'🧡 李女士'`/`'💛 张先生'`/`'💙 赵女士'`)
|
||||
3. THE API_Contract SHALL 为 `perf` 维度定义专属字段:`perfHours`(当期定档工时)、`perfHoursBefore`(上期定档工时,可选)、`perfGap`(距升档差距描述,可选)、`perfReached`(是否已达标)
|
||||
4. THE API_Contract SHALL 为 `salary` 维度定义专属字段:`salary`(工资总额)、`salaryPerfHours`(定档工时)、`salaryPerfBefore`(上期定档工时,可选)
|
||||
5. THE API_Contract SHALL 为 `sv` 维度定义专属字段:`svAmount`(客源储值总额)、`svCustomerCount`(储值客户数)、`svConsume`(储值消耗额)
|
||||
6. THE API_Contract SHALL 为 `task` 维度定义专属字段:`taskRecall`(召回任务完成数)、`taskCallback`(回访任务完成数)
|
||||
7. THE API_Contract SHALL 为 BOARD-1 定义请求参数:`sort`(6 种排序枚举:`perf_desc`/`perf_asc`/`salary_desc`/`salary_asc`/`sv_desc`/`task_desc`)、`skill`(5 种技能枚举:`all`/`chinese`/`snooker`/`mahjong`/`karaoke`)、`time`(6 种时间范围枚举:`month`/`quarter`/`last_month`/`last_3m`/`last_quarter`/`last_6m`)
|
||||
8. THE API_Contract SHALL 标注交叉约束:`time=last_6m` 与 `sort=sv_desc` 不兼容,后端收到此组合时返回 HTTP 400
|
||||
|
||||
#### 5.3 BOARD-2 客户看板契约重写
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE API_Contract SHALL 将 BOARD-2 响应从 8 个通用字段扩展为包含基础字段和 8 维度专属字段的结构
|
||||
2. THE API_Contract SHALL 为每个客户 item 定义基础字段:`id`/`name`/`initial`/`avatarCls`/`assistants`,其中 `assistants` 类型为 `Array<{ name: string, cls: string, heartScore: number, badge?: string, badgeCls?: string }>`,`heartScore` 范围 0-10,前端通过 heart-icon 组件按 P6 AC3 四级映射渲染(💖>8.5 / 🧡>7 / 💛>5 / 💙≤5)
|
||||
3. THE API_Contract SHALL 为 `recall`(最应召回)维度定义专属字段:`idealDays`/`elapsedDays`/`overdueDays`/`visits30d`/`balance`/`recallIndex`
|
||||
4. THE API_Contract SHALL 为 `potential`(最大消费潜力)维度定义专属字段:`potentialTags`(`Array<{ text: string, theme: string }>`)/`spend30d`/`avgVisits`/`avgSpend`
|
||||
5. THE API_Contract SHALL 为 `balance`(最高余额)维度定义专属字段:`balance`/`lastVisit`/`monthlyConsume`/`availableMonths`
|
||||
6. THE API_Contract SHALL 为 `recharge`(最近充值)维度定义专属字段:`lastRecharge`/`rechargeAmount`/`recharges60d`/`currentBalance`
|
||||
7. THE API_Contract SHALL 为 `recent`(最近到店)维度定义专属字段:`daysAgo`/`visitFreq`/`idealDays`/`visits30d`/`avgSpend`
|
||||
8. THE API_Contract SHALL 为 `spend60`(最高消费近60天)维度定义专属字段:`spend60d`/`visits60d`/`highSpendTag`/`avgSpend`
|
||||
9. THE API_Contract SHALL 为 `freq60`(最频繁近60天)维度定义专属字段:`visits60d`/`avgInterval`/`weeklyVisits`(`Array<{ val: number, pct: number }>`,8 周柱状图数据)/`spend60d`
|
||||
10. THE API_Contract SHALL 为 `loyal`(最专一近60天)维度定义专属字段:`intimacy`/`topCoachName`/`topCoachHeart`/`topCoachScore`/`coachName`/`coachRatio`/`coachDetails`(`Array<{ name, cls, heartScore, badge?, avgDuration, serviceCount, coachSpend, relationIdx }>`)
|
||||
11. THE API_Contract SHALL 为 BOARD-2 定义请求参数:`dimension`(8 种维度枚举)、`project`(5 种项目枚举:`all`/`chinese`/`snooker`/`mahjong`/`karaoke`)、`page`(页码,默认 1)、`pageSize`(每页条数,默认 20)
|
||||
|
||||
|
||||
#### 5.4 CUST-1 客户详情契约重写
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE API_Contract SHALL 为 CUST-1 响应补充客户 Banner 字段:`balance`(余额)、`consumption60d`(近60天消费)、`idealInterval`(理想到店间隔)、`daysSinceVisit`(距上次到店天数)
|
||||
2. THE API_Contract SHALL 为 CUST-1 响应补充 `aiInsight` 模块:`summary`(AI 分析摘要,string)和 `strategies`(策略建议列表,`Array<{ color: string, text: string }>`),数据来源标注为 `biz.ai_cache`(`cache_type=app4_analysis`)
|
||||
3. THE API_Contract SHALL 为 CUST-1 响应补充 `coachTasks` 模块(关联助教任务列表):每个助教含 `name`/`level`/`levelColor`/`taskType`/`taskColor`/`bgClass`/`status`/`lastService`/`metrics`(`Array<{ label, value, color? }>`,含近60天次数/总时长/次均时长)
|
||||
4. THE API_Contract SHALL 为 CUST-1 响应补充 `favoriteCoaches` 模块(最亲密助教):每位助教含 `emoji`/`name`/`relationIndex`/`indexColor`/`bgClass`/`stats`(`Array<{ label, value, color? }>`,含基础课时/激励课时/上课次数/充值金额)
|
||||
5. THE API_Contract SHALL 将 CUST-1 消费记录从扁平结构重写为嵌套结构:每条记录含 `id`/`type`(`table`/`shop`/`recharge` 枚举)/`date`/`tableName`/`startTime`/`endTime`/`duration`/`tableFee`/`tableOrigPrice`/`coaches`(`Array<{ name, level, levelColor, courseType, hours, perfHours?, fee }>`)/`foodAmount`/`foodOrigPrice`/`totalAmount`/`totalOrigPrice`/`payMethod`/`rechargeAmount`
|
||||
6. THE API_Contract SHALL 为 CUST-1 响应补充 `notes` 模块(备注列表):每条备注含 `id`/`tagLabel`/`createdAt`/`content`
|
||||
7. THE API_Contract SHALL 标注会员信息获取规则:通过 `member_id` JOIN `dim_member` 获取手机号和昵称(DWD-DOC 强制规则 DQ-6),禁止直接使用 `settlement_head.member_phone`
|
||||
|
||||
#### 5.5 COACH-1 助教详情契约重写
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE API_Contract SHALL 为 COACH-1 响应补充基本信息字段:`workYears`(工龄)、`customerCount`(客户数)、`hireDate`(入职日期)
|
||||
2. THE API_Contract SHALL 为 COACH-1 响应补充 `performance` 模块(6 个绩效指标):`monthlyHours`/`monthlySalary`/`customerBalance`/`tasksCompleted`/`perfCurrent`/`perfTarget`
|
||||
3. THE API_Contract SHALL 为 COACH-1 响应补充 `income` 模块(收入明细):`thisMonth` 和 `lastMonth` 各含 4 项收入分类(基础课时费/激励课时费/充值提成/酒水提成),每项含 `label`/`amount`/`color`
|
||||
4. THE API_Contract SHALL 为 COACH-1 响应补充 `tierNodes` 字段(档位节点数组,如 `[0, 100, 130, 160, 190, 220]`),供前端绩效进度条组件使用
|
||||
5. THE API_Contract SHALL 为 COACH-1 响应补充 `historyMonths` 模块(历史月份统计,最近 5+ 个月):每月含 `month`(标签)/`estimated`(是否预估)/`customers`/`hours`/`salary`/`callbackDone`/`recallDone`
|
||||
6. THE API_Contract SHALL 扩展 COACH-1 的 `topCustomers` 字段结构,从 5 个字段扩展为 10 个字段:补充 `initial`/`avatarGradient`/`heartEmoji`(P6 AC3 四级映射:💖/🧡/💛/💙)/`relationScore`(关系指数,0-10)/`scoreColor`/`balance`
|
||||
7. THE API_Contract SHALL 为 COACH-1 响应补充近期服务明细中的 `perfHours`(折算工时)和 `customerId` 字段
|
||||
8. THE API_Contract SHALL 为 COACH-1 的任务分组(`visibleTasks`/`hiddenTasks`/`abandonedTasks`)补充字段:TaskItem 补充 `noteCount`/`pinned`/`notes`(`Array<{ pinned?, text, date }>`),AbandonedTask 补充 `reason`
|
||||
9. THE API_Contract SHALL 为 COACH-1 响应补充 `notes` 模块(备注列表):每条备注含 `id`/`content`/`timestamp`/`aiScore`(AI 应用 6 评分,1-10,展示用)/`customerName`/`tagLabel`/`createdAt`
|
||||
|
||||
#### 5.6 PERF-1 绩效概览契约重写
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE API_Contract SHALL 将 PERF-1 的 `thisMonthRecords` 从扁平数组重写为按日期分组的 DateGroup 结构:每组含 `date`(日期标签)/`totalHours`(当日总工时)/`totalIncome`(当日总收入)/`records`(记录列表),每条记录含 `customerName`/`timeRange`/`hours`/`courseType`/`courseTypeClass`/`location`/`income`
|
||||
2. THE API_Contract SHALL 为 PERF-1 响应补充收入档位数据:`currentTier`(当前档,含 `basicRate`/`incentiveRate`)、`nextTier`(下一档,含 `basicRate`/`incentiveRate`)、`upgradeHoursNeeded`(距升档所需工时)、`upgradeBonus`(升档奖金)
|
||||
3. THE API_Contract SHALL 为 PERF-1 响应补充 `lastMonthIncome`(上月收入)字段
|
||||
4. THE API_Contract SHALL 为 PERF-1 的 `incomeItems` 每项补充 `desc` 字段(费率×工时的拆分描述,如 `"80元/h × 75h"`)
|
||||
5. THE API_Contract SHALL 为 PERF-1 的 `newCustomers` 补充 `lastService`(最后服务日期)和 `count`(服务次数)字段;为 `regularCustomers` 补充 `hours`(总工时)和 `income`(总收入)字段
|
||||
|
||||
#### 5.7 TASK-1 绩效概览字段契约重写
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE API_Contract SHALL 将 TASK-1 响应中的 `performance` 从 4 个字段(`totalHours`/`totalIncome`/`totalCustomers`/`monthLabel`)扩展为 15+ 个字段,补充:`tierNodes`(档位节点数组)/`basicHours`(基础课时)/`bonusHours`(激励课时)/`currentTier`(当前档位索引)/`nextTierHours`(下一档位工时阈值)/`tierCompleted`(是否已达标)/`bonusMoney`(升档奖金)/`incomeTrend`(收入趋势,如 `"↓368"`)/`incomeTrendDir`(`up`/`down`)/`prevMonth`(上月标签)
|
||||
2. THE API_Contract SHALL 为 TASK-1 的任务 item 补充可选扩展字段:`lastVisitDays`(距上次到店天数)、`balance`(客户余额)、`aiSuggestion`(AI 建议摘要)
|
||||
|
||||
#### 5.8 CHAT-1/2 对话模块契约重写
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE API_Contract SHALL 统一 CHAT-2 消息的时间字段名为 `createdAt`(替代前端使用的 `timestamp` 和契约定义的 `created_at`,遵循 camelCase 规范)
|
||||
2. THE API_Contract SHALL 为 CHAT-1 历史列表的每条记录补充 `title`(对话标题)字段
|
||||
3. THE API_Contract SHALL 为 CHAT-2 消息补充 `referenceCard` 可选字段:含 `type`(`customer`/`record` 枚举)/`title`/`summary`/`data`(`Record<string, string>` 键值对)
|
||||
4. THE API_Contract SHALL 补充 SSE 流式端点定义:`POST /api/xcx/chat/stream`,请求体含 `chatId`/`content`,响应为 `text/event-stream`
|
||||
5. THE API_Contract SHALL 标注 CHAT 消息查询端点支持 `customerId` 查询参数:后端根据 `customerId` 自动查找或创建对话,返回对应的 `chatId` 和消息列表
|
||||
|
||||
|
||||
### 需求 6:前端跨页面参数修复(T0-6)
|
||||
|
||||
**用户故事:** 作为助教或管理层用户,我希望在小程序中从一个页面跳转到另一个页面时,目标页面能正确加载对应的数据,而不会因为参数传递错误导致页面空白或加载错误数据。
|
||||
|
||||
#### 6.1 task-detail 页面跳转修复
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. WHEN 用户从 task-detail 页面点击"问问助手"跳转到 chat 页面时,THE Miniprogram SHALL 传递 `customerId={detail.customerId}` 参数(而非当前错误传递的 `detail.id` 即 taskId)
|
||||
2. WHEN 用户从 task-detail 页面点击"查看全部服务记录"跳转到 customer-service-records 页面时,THE Miniprogram SHALL 传递 `customerId={detail.customerId}` 参数(而非当前错误传递的 `detail.id` 即 taskId)
|
||||
3. IF TASK-2 响应中不包含 `customerId` 字段,THEN THE Miniprogram SHALL 无法完成上述跳转修复,因此本需求依赖 API 契约中 TASK-2 响应包含 `customerId` 字段的定义
|
||||
|
||||
#### 6.2 customer-detail 页面跳转修复
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. WHEN 用户从 customer-detail 页面点击"查看服务记录"跳转到 customer-service-records 页面时,THE Miniprogram SHALL 传递 `customerId={detail.id}` 参数(当前未传任何参数)
|
||||
2. WHEN 用户从 customer-detail 页面点击"问问助手"跳转到 chat 页面时,THE Miniprogram SHALL 传递 `customerId={detail.id}` 参数(当前未传任何参数)
|
||||
3. THE Miniprogram SHALL 修复 customer-detail 页面的 `loadDetail()` 函数,从 `onLoad(options)` 的 `options.id` 获取客户 ID,替代当前通过 `__route__` 解析的错误方式
|
||||
|
||||
#### 6.3 coach-detail 页面跳转修复
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. WHEN 用户从 coach-detail 页面点击任务项跳转到 customer-detail 页面时,THE Miniprogram SHALL 传递 `id={customerId}` 参数(而非当前错误传递的 `name={customerName}`),此修复依赖 COACH-1 响应中 TaskItem 包含 `customerId` 字段
|
||||
|
||||
#### 6.4 performance 页面跳转修复
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. WHEN 用户从 performance 页面点击客户卡片或服务记录跳转到 task-detail 页面时,THE Miniprogram SHALL 传递 `id={taskId}` 参数(而非当前错误传递的 `customerName={name}`),此修复依赖 PERF-1 响应中记录包含 `taskId` 字段
|
||||
|
||||
#### 6.5 chat 页面多入口参数路由
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. WHEN chat 页面从 task-detail 或 customer-detail 跳转进入(携带 `customerId` 参数)时,THE Miniprogram SHALL 使用 `customerId` 查询参数调用 CHAT 消息端点,由后端自动查找或创建对话
|
||||
2. WHEN chat 页面从 chat-history 跳转进入(携带 `historyId` 参数)时,THE Miniprogram SHALL 使用 `historyId` 作为 `chatId` 直接加载历史消息
|
||||
3. WHEN chat 页面从 coach-detail 跳转进入(携带 `coachId` 参数)时,THE Miniprogram SHALL 使用 `coachId` 作为上下文参数传递给 CHAT 端点
|
||||
|
||||
#### 6.6 globalData.authUser 字段扩展
|
||||
|
||||
##### 验收标准
|
||||
|
||||
1. THE Miniprogram SHALL 在登录成功后(`/api/xcx/me` 响应)将 `role`、`storeName`、`coachLevel`、`avatar` 字段存入 `globalData.authUser`,供 task-list Banner、performance Banner、performance-records Banner 等多个页面使用
|
||||
2. WHEN `globalData.authUser` 已包含上述字段时,THE Miniprogram SHALL 不再需要各页面单独请求 `/me` 接口获取这些信息
|
||||
|
||||
### 需求 7:契约文档一致性与完整性
|
||||
|
||||
**用户故事:** 作为后续子 spec(RNS1.1-1.4)的开发者,我希望重写后的 API 契约文档内部一致、无歧义,以便直接作为后端实现的唯一基准。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE API_Contract SHALL 确保所有接口的响应字段名统一使用 camelCase 格式(与需求 2 的 CamelCase_Converter 输出一致)
|
||||
2. THE API_Contract SHALL 确保所有涉及金额的字段标注使用 `items_sum` 口径,禁止使用 `consume_money`(DWD-DOC 强制规则 1)
|
||||
3. THE API_Contract SHALL 确保所有涉及助教费用的字段标注使用 `assistant_pd_money`(陪打)+ `assistant_cx_money`(超休)拆分,禁止使用 `service_fee`(DWD-DOC 强制规则 2)
|
||||
4. THE API_Contract SHALL 确保所有涉及会员信息的字段标注通过 `member_id` JOIN `dim_member` 获取,禁止直接使用 `member_phone`/`member_name`(DWD-DOC 强制规则 DQ-6/DQ-7)
|
||||
5. THE API_Contract SHALL 为每个接口标注数据源(FDW 表名或业务表名),供后续子 spec 实现时参考
|
||||
6. THE API_Contract SHALL 确保重写后的 8 个接口响应定义(BOARD-1/2/3、CUST-1、COACH-1、PERF-1、TASK-1 performance、CHAT-1/2)与前端内联 mock 数据结构完全对齐,无字段遗漏或类型不匹配
|
||||
251
.kiro/specs/rns1-infra-contract-rewrite/tasks.md
Normal file
251
.kiro/specs/rns1-infra-contract-rewrite/tasks.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# 实现计划:RNS1.0 基础设施与契约重写
|
||||
|
||||
## 概述
|
||||
|
||||
按依赖关系排序实现 6 个主任务:T0-1(全局中间件)和 T0-2(CamelModel)为基础设施层,必须先行;T0-3(路由修正)和 T0-4(前端解包)为适配层;T0-5(契约重写)为文档层;T0-6(参数修复)为前端修复层。属性测试使用 Hypothesis,单元测试使用 pytest。
|
||||
|
||||
## 任务
|
||||
|
||||
- [x] 1. 全局响应包装中间件 + 异常处理器(T0-1)
|
||||
- [x] 1.1 实现 ResponseWrapperMiddleware ASGI 中间件
|
||||
- 创建 `apps/backend/app/middleware/response_wrapper.py`
|
||||
- 实现 ASGI 中间件类,拦截 `http.response.start` 和 `http.response.body`
|
||||
- JSON 成功响应(2xx + application/json)包装为 `{ "code": 0, "data": <原始body> }`
|
||||
- 跳过条件:`text/event-stream`(SSE)、非 `application/json`、非 2xx 状态码
|
||||
- _需求: 1.1, 1.5, 1.6_
|
||||
|
||||
- [x] 1.2 实现异常处理器函数
|
||||
- 在同一文件中实现 `http_exception_handler` 和 `unhandled_exception_handler`
|
||||
- HTTPException → `{ "code": <status_code>, "message": <detail> }`
|
||||
- 未捕获异常 → `{ "code": 500, "message": "Internal Server Error" }` + `logger.exception` 写入完整堆栈
|
||||
- _需求: 1.2, 1.3_
|
||||
|
||||
- [x] 1.3 在 main.py 中注册中间件和异常处理器
|
||||
- `app.add_exception_handler(HTTPException, http_exception_handler)`
|
||||
- `app.add_exception_handler(Exception, unhandled_exception_handler)`
|
||||
- `app.add_middleware(ResponseWrapperMiddleware)`(在 CORS 之后添加)
|
||||
- 验证现有 16 个端点(Auth/Tasks/Notes/AI Chat)在包装后行为正常
|
||||
- _需求: 1.4_
|
||||
|
||||
- [x] 1.4 编写属性测试:响应包装-解包 Round Trip
|
||||
- **Property 1: 响应包装-解包 Round Trip**
|
||||
- 测试文件:`tests/test_rns1_properties.py`
|
||||
- 生成器:`st.from_type(dict|list|str|int|float|bool|None)` 生成随机 JSON 可序列化数据
|
||||
- 验证:包装后 JSON 包含 `code=0` 和 `data`;解包 `data` 等于原始输入
|
||||
- **验证需求: 1.1, 4.1**
|
||||
|
||||
- [x] 1.5 编写属性测试:异常响应保持 code 和 message
|
||||
- **Property 2: 异常响应保持 code 和 message**
|
||||
- 测试文件:`tests/test_rns1_properties.py`
|
||||
- 生成器:`st.integers(min_value=400, max_value=599)` × `st.text(min_size=1)`
|
||||
- 验证:输出 JSON 的 `code` 等于输入状态码,`message` 等于输入 detail
|
||||
- **验证需求: 1.2, 1.3**
|
||||
|
||||
- [x] 1.6 编写属性测试:非 JSON 响应透传
|
||||
- **Property 3: 非 JSON 响应透传**
|
||||
- 测试文件:`tests/test_rns1_properties.py`
|
||||
- 生成器:`st.sampled_from(["text/event-stream", "application/octet-stream", "text/html"])` × `st.binary()`
|
||||
- 验证:中间件输出的响应体与输入完全相同
|
||||
- **验证需求: 1.5, 1.6**
|
||||
|
||||
- [x] 1.7 编写单元测试:中间件跳过逻辑
|
||||
- 测试文件:`apps/backend/tests/unit/test_response_wrapper.py`
|
||||
- 用例:SSE 端点不包装;health 端点包装;非 2xx 不二次包装;中间件自身异常时透传原始响应
|
||||
- _需求: 1.5, 1.6_
|
||||
|
||||
- [x] 2. Pydantic Schema 统一 camelCase(T0-2)
|
||||
- [x] 2.1 创建 CamelModel 基类
|
||||
- 创建 `apps/backend/app/schemas/base.py`
|
||||
- 实现 `CamelModel(BaseModel)` 配置 `alias_generator=to_camel`、`populate_by_name=True`、`from_attributes=True`
|
||||
- _需求: 2.1_
|
||||
|
||||
- [x] 2.2 迁移现有 schema 到 CamelModel
|
||||
- `schemas/xcx_tasks.py`:`TaskListItem`、`AbandonRequest` 等所有类的 `BaseModel` → `CamelModel`
|
||||
- `schemas/xcx_auth.py`:所有 Auth schema 迁移
|
||||
- `schemas/xcx_notes.py`:所有 Notes schema 迁移
|
||||
- 确认路由函数 `response_model` 无需修改(Pydantic 自动使用 alias 序列化)
|
||||
- _需求: 2.2, 2.3_
|
||||
|
||||
- [x] 2.3 编写属性测试:camelCase 转换 Round Trip
|
||||
- **Property 4: camelCase 转换 Round Trip**
|
||||
- 测试文件:`tests/test_rns1_properties.py`
|
||||
- 生成器:`st.fixed_dictionaries` 生成随机 snake_case 字段名和值
|
||||
- 验证:`model_dump(by_alias=True)` → `Model(**camel_dict)` → 等于原始实例
|
||||
- **验证需求: 2.2, 2.4**
|
||||
|
||||
- [x] 2.4 编写单元测试:CamelModel 基类
|
||||
- 测试文件:`apps/backend/tests/unit/test_camel_model.py`
|
||||
- 用例:`TaskListItem` 序列化输出 camelCase 字段名;反序列化同时接受 snake_case 和 camelCase
|
||||
- _需求: 2.2, 2.4_
|
||||
|
||||
- [x] 3. 检查点 — 基础设施层验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
- 验证现有 16 个端点在中间件 + CamelModel 迁移后行为正常
|
||||
- 确认 JSON 响应格式为 `{ code: 0, data: { camelCaseFields... } }`
|
||||
|
||||
- [x] 4. 后端路由路径修正(T0-3)
|
||||
- [x] 4.1 修改路由路径和函数名
|
||||
- 文件:`apps/backend/app/routers/xcx_tasks.py`
|
||||
- `@router.post("/{task_id}/cancel-abandon")` → `@router.post("/{task_id}/restore")`
|
||||
- 函数名 `cancel_abandon` → `restore_task`(业务逻辑不变)
|
||||
- 移除旧路径,不保留兼容映射
|
||||
- _需求: 3.1, 3.2, 3.3_
|
||||
|
||||
- [x] 4.2 编写单元测试:路由路径修正
|
||||
- 测试文件:`apps/backend/tests/unit/test_xcx_tasks_route.py`
|
||||
- 用例:`POST /restore` 返回 200;`POST /cancel-abandon` 返回 404/405
|
||||
- _需求: 3.1, 3.3_
|
||||
|
||||
- [x] 5. 前端 request() 解包适配(T0-4)
|
||||
- [x] 5.1 修改 request() 函数添加 .data 解包逻辑
|
||||
- 文件:`apps/miniprogram/miniprogram/utils/request.ts`(或 `services/api.ts`,视实际位置)
|
||||
- `code === 0` → 返回 `res.data.data`(业务数据)
|
||||
- `code !== 0` → 抛出 `{ code, message }` 错误对象
|
||||
- 无 `code` 字段 → 直接返回原始响应体(SSE 等非标准格式)
|
||||
- 确保所有现有 API 调用(Auth/Tasks/Notes/AI Chat)解包后行为不变
|
||||
- _需求: 4.1, 4.2, 4.3, 4.4_
|
||||
|
||||
- [x] 5.2 编写属性测试:错误码解包抛出
|
||||
- **Property 5: 错误码解包抛出**
|
||||
- 测试文件:`tests/test_rns1_properties.py`
|
||||
- 生成器:`st.integers().filter(lambda x: x != 0)` × `st.text()`
|
||||
- 验证:解包函数对 `{ code: n, message: m }` 抛出异常,异常包含 code 和 message
|
||||
- **验证需求: 4.2**
|
||||
|
||||
- [x] 5.3 编写属性测试:非标准格式响应透传
|
||||
- **Property 6: 非标准格式响应透传**
|
||||
- 测试文件:`tests/test_rns1_properties.py`
|
||||
- 生成器:`st.dictionaries(st.text(), st.text()).filter(lambda d: "code" not in d)`
|
||||
- 验证:解包函数直接返回原始 dict
|
||||
- **验证需求: 4.3**
|
||||
|
||||
- [x] 6. 检查点 — 适配层验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
- 验证后端路由 `/restore` 可正常调用
|
||||
- 验证前端 request() 解包逻辑对成功/错误/非标准格式三种场景均正确
|
||||
|
||||
- [x] 7. API 契约完全重写(T0-5)
|
||||
- [x] 7.1 重写 BOARD-3 财务看板契约
|
||||
- 文件:`docs/miniprogram-dev/API-contract.md`
|
||||
- 定义 6 板块嵌套结构:overview / recharge / revenue / cashflow / expense / coachAnalysis
|
||||
- 定义请求参数枚举:time(8 种)、area(7 种)、compare(0/1)
|
||||
- 标注 `area≠all` 时 recharge 不返回;标注 `items_sum` 口径和助教费用拆分规则
|
||||
- 每个指标附带环比字段(`xxxCompare` + `isDown` + `isFlat`)
|
||||
- _需求: 5.1.1 ~ 5.1.10, 7.1 ~ 7.5_
|
||||
|
||||
- [x] 7.2 重写 BOARD-1 助教看板契约
|
||||
- 定义基础字段 + 4 维度专属字段(perf/salary/sv/task)
|
||||
- `skills` 类型为 `Array<{ text, cls }>`,`topCustomers` 类型为 `string[]`
|
||||
- 定义请求参数枚举:sort(6 种)、skill(5 种)、time(6 种)
|
||||
- 标注交叉约束:`time=last_6m` 与 `sort=sv_desc` 不兼容 → HTTP 400
|
||||
- _需求: 5.2.1 ~ 5.2.8, 7.1, 7.6_
|
||||
|
||||
- [x] 7.3 重写 BOARD-2 客户看板契约
|
||||
- 定义基础字段 + 8 维度专属字段(recall/potential/balance/recharge/recent/spend60/freq60/loyal)
|
||||
- `assistants` 含 heartScore/badge 等字段
|
||||
- 定义请求参数:dimension(8 种)、project(5 种)、page/pageSize(分页)
|
||||
- _需求: 5.3.1 ~ 5.3.11, 7.1_
|
||||
|
||||
- [x] 7.4 重写 CUST-1 客户详情契约
|
||||
- 补充 Banner 字段:balance/consumption60d/idealInterval/daysSinceVisit
|
||||
- 补充 aiInsight / coachTasks / favoriteCoaches / notes 模块
|
||||
- 消费记录重写为嵌套结构(含 coaches 子数组、tableFee/foodAmount 分项)
|
||||
- 标注会员信息 JOIN 规则(DQ-6)和 `items_sum` 口径
|
||||
- _需求: 5.4.1 ~ 5.4.7, 7.1 ~ 7.4_
|
||||
|
||||
- [x] 7.5 重写 COACH-1 助教详情契约
|
||||
- 补充 performance(6 指标)/ income(本月/上月各 4 项)/ tierNodes / historyMonths
|
||||
- 扩展 topCustomers(heartEmoji/score/balance)
|
||||
- 补充近期服务明细 perfHours/customerId
|
||||
- 任务分组补充 noteCount/pinned/notes/reason
|
||||
- 补充 notes 模块
|
||||
- _需求: 5.5.1 ~ 5.5.9, 7.1 ~ 7.4_
|
||||
|
||||
- [x] 7.6 重写 PERF-1 绩效概览契约
|
||||
- thisMonthRecords 改为 DateGroup 分组结构
|
||||
- 补充收入档位数据:currentTier/nextTier/upgradeHoursNeeded/upgradeBonus
|
||||
- 补充 lastMonthIncome、incomeItems.desc
|
||||
- 补充 newCustomers 和 regularCustomers 扩展字段
|
||||
- _需求: 5.6.1 ~ 5.6.5, 7.1_
|
||||
|
||||
- [x] 7.7 重写 TASK-1 绩效概览字段契约
|
||||
- performance 从 4 字段扩展为 15+ 字段
|
||||
- 补充 tierNodes/basicHours/bonusHours/currentTier/nextTierHours/tierCompleted/bonusMoney/incomeTrend/incomeTrendDir/prevMonth
|
||||
- 任务 item 补充 lastVisitDays/balance/aiSuggestion
|
||||
- _需求: 5.7.1, 5.7.2_
|
||||
|
||||
- [x] 7.8 重写 CHAT-1/2 对话模块契约
|
||||
- 统一时间字段名为 `createdAt`
|
||||
- CHAT-1 补充 `title` 字段
|
||||
- CHAT-2 补充 `referenceCard` 可选字段
|
||||
- 补充 SSE 流式端点定义:`POST /api/xcx/chat/stream`
|
||||
- 标注 `customerId` 查询参数支持
|
||||
- _需求: 5.8.1 ~ 5.8.5_
|
||||
|
||||
- [x] 7.9 契约一致性审查
|
||||
- 确保所有字段名统一 camelCase
|
||||
- 确保所有金额字段标注 `items_sum` 口径
|
||||
- 确保所有助教费用标注 `assistant_pd_money` + `assistant_cx_money` 拆分
|
||||
- 确保所有会员信息标注 `member_id` JOIN `dim_member` 规则
|
||||
- 每个接口标注数据源(FDW 表名或业务表名)
|
||||
- 对照前端 mock 数据结构逐字段比对,确保无遗漏或类型不匹配
|
||||
- _需求: 7.1 ~ 7.6_
|
||||
|
||||
- [x] 8. 检查点 — 契约文档验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
- 确认 8 个接口契约定义完整、字段名 camelCase、金额口径标注正确
|
||||
- 确认契约文档可作为后续子 spec(RNS1.1-1.4)的唯一实现基准
|
||||
|
||||
- [x] 9. 前端跨页面参数修复(T0-6)
|
||||
- [x] 9.1 修复 task-detail 页面跳转参数
|
||||
- 文件:`apps/miniprogram/miniprogram/pages/task-detail/task-detail.ts`
|
||||
- 跳转 chat 时传 `customerId={detail.customerId}`(而非 `detail.id`)
|
||||
- 跳转 customer-service-records 时传 `customerId={detail.customerId}`(而非 `detail.id`)
|
||||
- _需求: 6.1.1, 6.1.2_
|
||||
|
||||
- [x] 9.2 修复 customer-detail 页面跳转参数和 ID 获取方式
|
||||
- 文件:`apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.ts`
|
||||
- 跳转 customer-service-records 时传 `customerId={detail.id}`
|
||||
- 跳转 chat 时传 `customerId={detail.id}`
|
||||
- `loadDetail()` 从 `onLoad(options)` 的 `options.id` 获取客户 ID,替代 `__route__` 解析
|
||||
- _需求: 6.2.1, 6.2.2, 6.2.3_
|
||||
|
||||
- [x] 9.3 修复 coach-detail 页面跳转参数
|
||||
- 文件:`apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts`
|
||||
- 任务项跳转 customer-detail 时传 `id={customerId}`(而非 `name={customerName}`)
|
||||
- 依赖 COACH-1 响应中 TaskItem 包含 `customerId` 字段
|
||||
- _需求: 6.3.1_
|
||||
|
||||
- [x] 9.4 修复 performance 页面跳转参数
|
||||
- 文件:`apps/miniprogram/miniprogram/pages/performance/performance.ts`
|
||||
- 跳转 task-detail 时传 `id={taskId}`(而非 `customerName={name}`)
|
||||
- 依赖 PERF-1 响应中记录包含 `taskId` 字段
|
||||
- _需求: 6.4.1_
|
||||
|
||||
- [x] 9.5 实现 chat 页面多入口参数路由
|
||||
- 文件:`apps/miniprogram/miniprogram/pages/chat/chat.ts`
|
||||
- 支持 `customerId` 入口:使用 `customerId` 查询参数调用 CHAT 端点
|
||||
- 支持 `historyId` 入口:使用 `historyId` 作为 `chatId` 加载历史消息
|
||||
- 支持 `coachId` 入口:使用 `coachId` 作为上下文参数
|
||||
- _需求: 6.5.1, 6.5.2, 6.5.3_
|
||||
|
||||
- [x] 9.6 扩展 globalData.authUser 字段
|
||||
- 文件:`apps/miniprogram/miniprogram/app.ts`
|
||||
- 登录成功后将 `role`/`storeName`/`coachLevel`/`avatar` 存入 `globalData.authUser`
|
||||
- 各页面 Banner 直接从 `globalData.authUser` 读取,不再单独请求 `/me`
|
||||
- _需求: 6.6.1, 6.6.2_
|
||||
|
||||
- [x] 10. 最终检查点 — 全量验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
- 验证后端:中间件包装 + CamelModel + 路由修正 + 异常处理器全部生效
|
||||
- 验证前端:request() 解包 + 8 个跳转场景参数正确 + globalData 字段完整
|
||||
- 验证契约:8 个接口定义完整,可作为 RNS1.1-1.4 实现基准
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选测试任务,可跳过以加速 MVP
|
||||
- 每个任务引用具体需求编号,确保可追溯
|
||||
- 属性测试使用 Hypothesis(`tests/test_rns1_properties.py`),单元测试使用 pytest(`apps/backend/tests/unit/`)
|
||||
- 检查点确保增量验证,避免问题累积
|
||||
- T0-5 契约重写为文档任务,产出物为 `docs/miniprogram-dev/API-contract.md`
|
||||
- T0-6 前端参数修复中部分跳转依赖后续子 spec 的 API 响应字段(如 COACH-1 的 `customerId`、PERF-1 的 `taskId`),当前先修改跳转代码,待后端实现后联调验证
|
||||
1
.kiro/specs/rns1-task-performance-api/.config.kiro
Normal file
1
.kiro/specs/rns1-task-performance-api/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "a7e3c1d4-8f2b-4e6a-b5d9-3c1f7a2e8b4d", "workflowType": "requirements-first", "specType": "feature"}
|
||||
930
.kiro/specs/rns1-task-performance-api/design.md
Normal file
930
.kiro/specs/rns1-task-performance-api/design.md
Normal file
@@ -0,0 +1,930 @@
|
||||
# 技术设计文档 — RNS1.1:任务与绩效接口
|
||||
|
||||
## 概述
|
||||
|
||||
RNS1.1 实现助教日常使用频率最高的 4 个核心接口及配套前端适配,覆盖 6 个任务:
|
||||
|
||||
1. **T1-1 扩展 TASK-1**:任务列表 `performance` 从 4 字段扩展到 15+ 字段;`enrichTask` 补充 `lastVisitDays`/`balance`/`aiSuggestion`
|
||||
2. **T1-2 实现 TASK-2**:任务详情完整版(维客线索、话术、服务记录、AI 分析、备注)
|
||||
3. **T1-3 实现 PERF-1**:绩效概览(DateGroup 分组、收入档位、新客/常客列表)
|
||||
4. **T1-4 实现 PERF-2**:绩效明细(按月分页、DateGroup 分组)
|
||||
5. **T1-5 pin/unpin**:已有端点的响应格式对齐契约
|
||||
6. **T1-6 前端适配**:createNote 补 score、月份切换、avatarChar/avatarColor 前端计算
|
||||
|
||||
### 设计原则
|
||||
|
||||
- **增量扩展**:在现有 `xcx_tasks.py` 路由和 `task_manager.py` 服务基础上扩展,不重写
|
||||
- **FDW 查询集中化**:所有 FDW 查询封装在 service 层,路由层不直接操作数据库
|
||||
- **DWD-DOC 强制规则**:金额 `items_sum` 口径、助教费用 `assistant_pd_money` + `assistant_cx_money` 拆分、会员信息通过 `member_id` JOIN `dim_member`
|
||||
- **契约驱动**:响应结构严格遵循 `API-contract.md` 定义
|
||||
|
||||
### 依赖
|
||||
|
||||
- RNS1.0 已完成:`ResponseWrapperMiddleware`、`CamelModel`、前端 `request()` 解包
|
||||
- 现有代码:`xcx_tasks.py`(路由)、`task_manager.py`(服务)、`xcx_notes.py`(备注路由)
|
||||
|
||||
## 架构
|
||||
|
||||
### 模块交互
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "微信小程序"
|
||||
A[task-list 页面] --> B[services/api.ts]
|
||||
C[task-detail 页面] --> B
|
||||
D[performance 页面] --> B
|
||||
E[performance-records 页面] --> B
|
||||
end
|
||||
|
||||
subgraph "FastAPI 后端"
|
||||
F[routers/xcx_tasks.py<br/>TASK-1/2, PIN/UNPIN]
|
||||
G[routers/xcx_performance.py<br/>PERF-1/2 — 新增]
|
||||
H[services/task_manager.py<br/>任务 CRUD + 扩展]
|
||||
I[services/performance_service.py<br/>绩效查询 — 新增]
|
||||
J[services/fdw_queries.py<br/>FDW 查询封装 — 新增]
|
||||
end
|
||||
|
||||
subgraph "数据库"
|
||||
K[(zqyy_app<br/>biz.coach_tasks<br/>biz.ai_cache<br/>biz.notes<br/>auth.user_assistant_binding<br/>public.member_retention_clue)]
|
||||
L[(etl_feiqiu via FDW<br/>fdw_etl.v_dws_assistant_salary_calc<br/>fdw_etl.v_dwd_assistant_service_log<br/>fdw_etl.v_dim_member<br/>fdw_etl.v_dim_member_card_account)]
|
||||
end
|
||||
|
||||
B -->|HTTP JSON| F
|
||||
B -->|HTTP JSON| G
|
||||
F --> H
|
||||
G --> I
|
||||
H --> J
|
||||
I --> J
|
||||
H --> K
|
||||
I --> K
|
||||
J -->|FDW + SET LOCAL| L
|
||||
|
||||
style G fill:#9f9,stroke:#333
|
||||
style I fill:#9f9,stroke:#333
|
||||
style J fill:#9f9,stroke:#333
|
||||
```
|
||||
|
||||
### 请求流程(以 TASK-1 扩展为例)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MP as 小程序
|
||||
participant R as xcx_tasks Router
|
||||
participant TM as task_manager
|
||||
participant FDW as fdw_queries
|
||||
participant BIZ as zqyy_app
|
||||
participant ETL as etl via FDW
|
||||
|
||||
MP->>R: GET /api/xcx/tasks?status=pending&page=1
|
||||
R->>TM: get_task_list(user_id, site_id, status, page, pageSize)
|
||||
TM->>BIZ: SELECT assistant_id FROM user_assistant_binding
|
||||
TM->>BIZ: SELECT tasks FROM coach_tasks
|
||||
TM->>FDW: enrich_tasks(member_ids, assistant_id, site_id)
|
||||
FDW->>ETL: SET LOCAL app.current_site_id
|
||||
FDW->>ETL: SELECT dim_member (姓名)
|
||||
FDW->>ETL: SELECT dim_member_card_account (余额)
|
||||
FDW->>ETL: SELECT dwd_settlement_head (lastVisitDays)
|
||||
FDW-->>TM: enriched data
|
||||
TM->>FDW: get_performance_summary(assistant_id, site_id)
|
||||
FDW->>ETL: SELECT v_dws_assistant_salary_calc (档位/收入)
|
||||
FDW-->>TM: PerformanceSummary
|
||||
TM->>BIZ: SELECT ai_cache (aiSuggestion)
|
||||
TM-->>R: TaskListResponse
|
||||
R-->>MP: { code: 0, data: TaskListResponse }
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
### 组件 1:xcx_tasks Router 扩展
|
||||
|
||||
**位置**:`apps/backend/app/routers/xcx_tasks.py`
|
||||
|
||||
**改动**:
|
||||
- `GET /api/xcx/tasks` — 响应从 `list[TaskListItem]` 改为 `TaskListResponse`(含 `items` + `performance` + 分页)
|
||||
- 新增 `GET /api/xcx/tasks/{taskId}` — 任务详情端点
|
||||
- `POST .../pin` 和 `POST .../unpin` — 响应对齐契约格式 `{ isPinned: bool }`
|
||||
- 新增查询参数:`status`(筛选)、`page`、`pageSize`
|
||||
|
||||
```python
|
||||
@router.get("", response_model=TaskListResponse)
|
||||
async def get_tasks(
|
||||
status: str = Query("pending", regex="^(pending|completed|abandoned)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""获取任务列表 + 绩效概览。"""
|
||||
return await task_manager.get_task_list_v2(
|
||||
user.user_id, user.site_id, status, page, page_size
|
||||
)
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskDetailResponse)
|
||||
async def get_task_detail(
|
||||
task_id: int,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""获取任务详情完整版。"""
|
||||
return await task_manager.get_task_detail(
|
||||
task_id, user.user_id, user.site_id
|
||||
)
|
||||
```
|
||||
|
||||
### 组件 2:xcx_performance Router(新增)
|
||||
|
||||
**位置**:`apps/backend/app/routers/xcx_performance.py`
|
||||
|
||||
**职责**:绩效概览和绩效明细两个端点。
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/api/xcx/performance", tags=["小程序绩效"])
|
||||
|
||||
@router.get("", response_model=PerformanceOverviewResponse)
|
||||
async def get_performance_overview(
|
||||
year: int = Query(...),
|
||||
month: int = Query(..., ge=1, le=12),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""绩效概览(PERF-1)。"""
|
||||
return await performance_service.get_overview(
|
||||
user.user_id, user.site_id, year, month
|
||||
)
|
||||
|
||||
@router.get("/records", response_model=PerformanceRecordsResponse)
|
||||
async def get_performance_records(
|
||||
year: int = Query(...),
|
||||
month: int = Query(..., ge=1, le=12),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
):
|
||||
"""绩效明细(PERF-2)。"""
|
||||
return await performance_service.get_records(
|
||||
user.user_id, user.site_id, year, month, page, page_size
|
||||
)
|
||||
```
|
||||
|
||||
**注册**(`main.py`):
|
||||
```python
|
||||
from app.routers import xcx_performance
|
||||
app.include_router(xcx_performance.router)
|
||||
```
|
||||
|
||||
### 组件 3:fdw_queries 服务(新增)
|
||||
|
||||
**位置**:`apps/backend/app/services/fdw_queries.py`
|
||||
|
||||
**职责**:封装所有 FDW 查询,统一 `SET LOCAL app.current_site_id` 隔离。所有 FDW 查询集中在此模块,避免 service 层散落 SQL。
|
||||
|
||||
**核心函数**:
|
||||
|
||||
```python
|
||||
def _fdw_context(conn, site_id: int):
|
||||
"""上下文管理器:BEGIN + SET LOCAL app.current_site_id。"""
|
||||
...
|
||||
|
||||
def get_member_info(conn, site_id: int, member_ids: list[int]) -> dict[int, MemberInfo]:
|
||||
"""
|
||||
批量查询会员信息。
|
||||
⚠️ DQ-6:通过 member_id JOIN fdw_etl.v_dim_member 获取 nickname/mobile,
|
||||
禁止使用 settlement_head.member_phone/member_name。
|
||||
"""
|
||||
# SELECT member_id, nickname, mobile
|
||||
# FROM fdw_etl.v_dim_member
|
||||
# WHERE member_id = ANY(%s) AND scd2_is_current = 1
|
||||
|
||||
def get_member_balance(conn, site_id: int, member_ids: list[int]) -> dict[int, Decimal]:
|
||||
"""
|
||||
批量查询会员储值卡余额。
|
||||
⚠️ DQ-7:通过 member_id JOIN fdw_etl.v_dim_member_card_account。
|
||||
"""
|
||||
|
||||
def get_last_visit_days(conn, site_id: int, member_ids: list[int]) -> dict[int, int | None]:
|
||||
"""
|
||||
批量查询客户距上次到店天数。
|
||||
来源:fdw_etl.v_dwd_assistant_service_log 或 dwd_settlement_head。
|
||||
"""
|
||||
|
||||
def get_salary_calc(conn, site_id: int, assistant_id: int, year: int, month: int) -> dict | None:
|
||||
"""
|
||||
查询助教绩效/档位/收入数据。
|
||||
来源:fdw_etl.v_dws_assistant_salary_calc。
|
||||
⚠️ DWD-DOC 规则 1:收入使用 items_sum 口径。
|
||||
⚠️ DWD-DOC 规则 2:费用使用 assistant_pd_money + assistant_cx_money。
|
||||
"""
|
||||
|
||||
def get_service_records(
|
||||
conn, site_id: int, assistant_id: int,
|
||||
year: int, month: int, limit: int, offset: int
|
||||
) -> list[dict]:
|
||||
"""
|
||||
查询助教服务记录明细。
|
||||
来源:fdw_etl.v_dwd_assistant_service_log。
|
||||
⚠️ 废单排除:WHERE is_trash = false。
|
||||
⚠️ DQ-6:客户姓名通过 member_id JOIN dim_member。
|
||||
"""
|
||||
|
||||
def get_service_records_for_task(
|
||||
conn, site_id: int, assistant_id: int, member_id: int, limit: int
|
||||
) -> list[dict]:
|
||||
"""查询特定客户的服务记录(TASK-2 用)。"""
|
||||
```
|
||||
|
||||
### 组件 4:task_manager 服务扩展
|
||||
|
||||
**位置**:`apps/backend/app/services/task_manager.py`
|
||||
|
||||
**新增函数**:
|
||||
|
||||
```python
|
||||
async def get_task_list_v2(
|
||||
user_id: int, site_id: int,
|
||||
status: str, page: int, page_size: int
|
||||
) -> dict:
|
||||
"""
|
||||
扩展版任务列表(TASK-1)。
|
||||
返回 { items, total, page, pageSize, performance }。
|
||||
"""
|
||||
|
||||
async def get_task_detail(
|
||||
task_id: int, user_id: int, site_id: int
|
||||
) -> dict:
|
||||
"""
|
||||
任务详情完整版(TASK-2)。
|
||||
返回基础信息 + retentionClues + talkingPoints + serviceSummary
|
||||
+ serviceRecords + aiAnalysis + notes + customerId。
|
||||
"""
|
||||
```
|
||||
|
||||
**`get_task_list_v2` 逻辑**:
|
||||
1. `_get_assistant_id()` 获取 `assistant_id`
|
||||
2. 查询 `biz.coach_tasks` 带分页(`LIMIT/OFFSET` + `COUNT(*)`)
|
||||
3. 调用 `fdw_queries` 批量获取会员信息、余额、lastVisitDays
|
||||
4. 调用 `fdw_queries.get_salary_calc()` 获取绩效概览
|
||||
5. 查询 `biz.ai_cache` 获取 aiSuggestion
|
||||
6. 组装 `TaskListResponse`
|
||||
|
||||
**`get_task_detail` 逻辑**:
|
||||
1. `_get_assistant_id()` + `_verify_task_ownership()` 权限校验
|
||||
2. 查询 `biz.coach_tasks` 基础信息
|
||||
3. 查询 `public.member_retention_clue` 维客线索
|
||||
4. 查询 `biz.ai_cache`(`app5_talking_points` → talkingPoints,`app4_analysis` → aiAnalysis)
|
||||
5. 调用 `fdw_queries.get_service_records_for_task()` 获取服务记录(最多 20 条)
|
||||
6. 查询 `biz.notes` 获取备注(最多 20 条)
|
||||
7. 组装 `TaskDetailResponse`
|
||||
|
||||
### 组件 5:performance_service(新增)
|
||||
|
||||
**位置**:`apps/backend/app/services/performance_service.py`
|
||||
|
||||
**核心函数**:
|
||||
|
||||
```python
|
||||
async def get_overview(
|
||||
user_id: int, site_id: int, year: int, month: int
|
||||
) -> dict:
|
||||
"""
|
||||
绩效概览(PERF-1)。
|
||||
1. 获取 assistant_id
|
||||
2. fdw_queries.get_salary_calc() → 档位/收入/费率
|
||||
3. fdw_queries.get_service_records() → 按日期分组为 DateGroup
|
||||
4. 聚合新客/常客列表
|
||||
5. 计算 incomeItems(含 desc 费率描述)
|
||||
6. 查询上月收入 lastMonthIncome
|
||||
"""
|
||||
|
||||
async def get_records(
|
||||
user_id: int, site_id: int,
|
||||
year: int, month: int, page: int, page_size: int
|
||||
) -> dict:
|
||||
"""
|
||||
绩效明细(PERF-2)。
|
||||
1. 获取 assistant_id
|
||||
2. fdw_queries.get_service_records() 带分页
|
||||
3. 按日期分组为 dateGroups
|
||||
4. 计算 summary 汇总
|
||||
5. 返回 { summary, dateGroups, hasMore }
|
||||
"""
|
||||
```
|
||||
|
||||
### 组件 6:Pydantic Schema(新增/扩展)
|
||||
|
||||
**位置**:`apps/backend/app/schemas/xcx_tasks.py`(扩展)+ `apps/backend/app/schemas/xcx_performance.py`(新增)
|
||||
|
||||
#### 任务相关 Schema 扩展
|
||||
|
||||
```python
|
||||
class PerformanceSummary(CamelModel):
|
||||
"""绩效概览(附带在任务列表响应中)。"""
|
||||
total_hours: float
|
||||
total_income: float
|
||||
total_customers: int
|
||||
month_label: str
|
||||
tier_nodes: list[float]
|
||||
basic_hours: float
|
||||
bonus_hours: float
|
||||
current_tier: int
|
||||
next_tier_hours: float
|
||||
tier_completed: bool
|
||||
bonus_money: float
|
||||
income_trend: str
|
||||
income_trend_dir: str # 'up' | 'down'
|
||||
prev_month: str
|
||||
current_tier_label: str
|
||||
|
||||
class TaskItem(CamelModel):
|
||||
"""任务列表项(扩展版)。"""
|
||||
id: int
|
||||
customer_name: str
|
||||
customer_avatar: str
|
||||
task_type: str
|
||||
task_type_label: str
|
||||
deadline: str | None
|
||||
heart_score: float
|
||||
hobbies: list[str]
|
||||
is_pinned: bool
|
||||
has_note: bool
|
||||
status: str
|
||||
last_visit_days: int | None = None
|
||||
balance: float | None = None
|
||||
ai_suggestion: str | None = None
|
||||
|
||||
class TaskListResponse(CamelModel):
|
||||
"""TASK-1 响应。"""
|
||||
items: list[TaskItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
performance: PerformanceSummary
|
||||
```
|
||||
|
||||
#### 任务详情 Schema
|
||||
|
||||
```python
|
||||
class RetentionClue(CamelModel):
|
||||
tag: str
|
||||
tag_color: str
|
||||
emoji: str
|
||||
text: str
|
||||
source: str # 'manual' | 'ai_consumption' | 'ai_note'
|
||||
desc: str | None = None
|
||||
|
||||
class ServiceRecord(CamelModel):
|
||||
table: str | None = None
|
||||
type: str
|
||||
type_class: str # 'basic' | 'vip' | 'tip' | 'recharge' | 'incentive'
|
||||
record_type: str | None = None # 'course' | 'recharge'
|
||||
duration: float
|
||||
duration_raw: float | None = None
|
||||
income: float
|
||||
is_estimate: bool | None = None
|
||||
drinks: str | None = None
|
||||
date: str
|
||||
|
||||
class AiAnalysis(CamelModel):
|
||||
summary: str
|
||||
suggestions: list[str]
|
||||
|
||||
class NoteItem(CamelModel):
|
||||
id: int
|
||||
content: str
|
||||
tag_type: str
|
||||
tag_label: str
|
||||
created_at: str
|
||||
score: int | None = None
|
||||
|
||||
class ServiceSummary(CamelModel):
|
||||
total_hours: float
|
||||
total_income: float
|
||||
avg_income: float
|
||||
|
||||
class TaskDetailResponse(CamelModel):
|
||||
"""TASK-2 响应。"""
|
||||
# 基础信息
|
||||
id: int
|
||||
customer_name: str
|
||||
customer_avatar: str
|
||||
task_type: str
|
||||
task_type_label: str
|
||||
deadline: str | None
|
||||
heart_score: float
|
||||
hobbies: list[str]
|
||||
is_pinned: bool
|
||||
has_note: bool
|
||||
status: str
|
||||
customer_id: int
|
||||
# 扩展模块
|
||||
retention_clues: list[RetentionClue]
|
||||
talking_points: list[str]
|
||||
service_summary: ServiceSummary
|
||||
service_records: list[ServiceRecord]
|
||||
ai_analysis: AiAnalysis
|
||||
notes: list[NoteItem]
|
||||
```
|
||||
|
||||
#### 绩效 Schema
|
||||
|
||||
```python
|
||||
class DateGroupRecord(CamelModel):
|
||||
customer_name: str
|
||||
avatar_char: str | None = None # PERF-1 返回,PERF-2 不返回
|
||||
avatar_color: str | None = None # PERF-1 返回,PERF-2 不返回
|
||||
time_range: str
|
||||
hours: str
|
||||
course_type: str
|
||||
course_type_class: str # 'basic' | 'vip' | 'tip'
|
||||
location: str
|
||||
income: str
|
||||
|
||||
class DateGroup(CamelModel):
|
||||
date: str
|
||||
total_hours: str
|
||||
total_income: str
|
||||
records: list[DateGroupRecord]
|
||||
|
||||
class TierInfo(CamelModel):
|
||||
basic_rate: float
|
||||
incentive_rate: float
|
||||
|
||||
class IncomeItem(CamelModel):
|
||||
icon: str
|
||||
label: str
|
||||
desc: str
|
||||
value: str
|
||||
|
||||
class CustomerSummary(CamelModel):
|
||||
name: str
|
||||
avatar_char: str
|
||||
avatar_color: str
|
||||
|
||||
class NewCustomer(CustomerSummary):
|
||||
last_service: str
|
||||
count: int
|
||||
|
||||
class RegularCustomer(CustomerSummary):
|
||||
hours: float
|
||||
income: str
|
||||
count: int
|
||||
|
||||
class PerformanceOverviewResponse(CamelModel):
|
||||
"""PERF-1 响应。"""
|
||||
coach_name: str
|
||||
coach_role: str
|
||||
store_name: str
|
||||
monthly_income: str
|
||||
last_month_income: str
|
||||
current_tier: TierInfo
|
||||
next_tier: TierInfo
|
||||
upgrade_hours_needed: float
|
||||
upgrade_bonus: float
|
||||
income_items: list[IncomeItem]
|
||||
monthly_total: str
|
||||
this_month_records: list[DateGroup]
|
||||
new_customers: list[NewCustomer]
|
||||
regular_customers: list[RegularCustomer]
|
||||
|
||||
class RecordsSummary(CamelModel):
|
||||
total_count: int
|
||||
total_hours: float
|
||||
total_hours_raw: float
|
||||
total_income: float
|
||||
|
||||
class PerformanceRecordsResponse(CamelModel):
|
||||
"""PERF-2 响应。"""
|
||||
summary: RecordsSummary
|
||||
date_groups: list[DateGroup]
|
||||
has_more: bool
|
||||
```
|
||||
|
||||
### 组件 7:前端适配
|
||||
|
||||
**涉及文件与改动**:
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `services/api.ts` | 新增 `pinTask()`、`unpinTask()`;`createNote()` 增加 `score` 参数;新增 `fetchTaskDetail()`、`fetchPerformanceOverview(year, month)`、`fetchPerformanceRecords(year, month, page)` |
|
||||
| `pages/task-list/task-list.ts` | 消费 `TaskListResponse` 新结构;`buildPerfData()` 使用 15+ 字段绩效数据 |
|
||||
| `pages/task-detail/task-detail.ts` | 调用 `fetchTaskDetail()`;`storageLevel`/`relationLevel` 前端本地计算 |
|
||||
| `pages/performance/performance.ts` | 添加月份切换控件;切换时重新调用 API |
|
||||
| `pages/performance-records/performance-records.ts` | 月份切换时重置 `page=1`;`avatarChar`/`avatarColor` 使用 `nameToAvatarColor()` 前端计算 |
|
||||
|
||||
|
||||
|
||||
## 数据模型
|
||||
|
||||
### FDW 查询模式
|
||||
|
||||
所有 FDW 查询遵循统一模式:
|
||||
|
||||
```python
|
||||
# 1. 使用业务库连接(get_connection() → zqyy_app)
|
||||
conn = get_connection()
|
||||
|
||||
# 2. 开启事务 + 设置门店隔离
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute("SET LOCAL app.current_site_id = %s", (str(site_id),))
|
||||
|
||||
# 3. 通过 fdw_etl schema 访问 ETL 视图
|
||||
cur.execute("""
|
||||
SELECT ...
|
||||
FROM fdw_etl.v_dws_assistant_salary_calc
|
||||
WHERE assistant_id = %s AND calc_month = %s
|
||||
""", (assistant_id, f"{year}-{month:02d}"))
|
||||
|
||||
conn.commit()
|
||||
```
|
||||
|
||||
### 核心 SQL 查询设计
|
||||
|
||||
#### Q1: 绩效概览查询(TASK-1 performance + PERF-1)
|
||||
|
||||
```sql
|
||||
-- ⚠️ DWD-DOC 规则 1: 收入使用 items_sum 口径
|
||||
-- ⚠️ DWD-DOC 规则 2: 费用使用 assistant_pd_money + assistant_cx_money
|
||||
SELECT
|
||||
calc_month,
|
||||
coach_level, -- 当前档位名称
|
||||
tier_index, -- 当前档位索引
|
||||
tier_nodes, -- 档位节点数组 (JSON)
|
||||
basic_hours, -- 基础课时 (assistant_pd_money 对应)
|
||||
bonus_hours, -- 激励课时 (assistant_cx_money 对应)
|
||||
total_hours, -- 总工时
|
||||
total_income, -- 总收入 (items_sum 口径)
|
||||
total_customers, -- 服务客户数
|
||||
basic_rate, -- 基础课时费率
|
||||
incentive_rate, -- 激励课时费率
|
||||
next_tier_basic_rate, -- 下一档基础课时费率
|
||||
next_tier_incentive_rate, -- 下一档激励课时费率
|
||||
next_tier_hours, -- 下一档工时阈值
|
||||
tier_completed, -- 是否已达最高档
|
||||
bonus_money, -- 升档奖金
|
||||
assistant_pd_money_total, -- 基础课总费用
|
||||
assistant_cx_money_total -- 激励课总费用
|
||||
FROM fdw_etl.v_dws_assistant_salary_calc
|
||||
WHERE assistant_id = %s
|
||||
AND calc_month = %s
|
||||
```
|
||||
|
||||
#### Q2: 收入趋势计算(TASK-1 performance)
|
||||
|
||||
```sql
|
||||
-- 当月 vs 上月收入对比
|
||||
WITH months AS (
|
||||
SELECT total_income, calc_month
|
||||
FROM fdw_etl.v_dws_assistant_salary_calc
|
||||
WHERE assistant_id = %s
|
||||
AND calc_month IN (%s, %s) -- 当月, 上月
|
||||
)
|
||||
SELECT * FROM months ORDER BY calc_month
|
||||
```
|
||||
|
||||
后端计算逻辑:
|
||||
```python
|
||||
diff = current_income - prev_income
|
||||
income_trend = f"{'↑' if diff >= 0 else '↓'}{abs(diff):.0f}"
|
||||
income_trend_dir = "up" if diff >= 0 else "down"
|
||||
```
|
||||
|
||||
#### Q3: 服务记录查询(PERF-1 thisMonthRecords / PERF-2 dateGroups / TASK-2 serviceRecords)
|
||||
|
||||
```sql
|
||||
-- ⚠️ DWD-DOC 规则 DQ-6: 客户姓名通过 member_id JOIN dim_member
|
||||
-- ⚠️ 废单排除: is_trash = false
|
||||
SELECT
|
||||
sl.id,
|
||||
dm.nickname AS customer_name,
|
||||
sl.settle_time,
|
||||
sl.start_time,
|
||||
sl.end_time,
|
||||
sl.service_hours, -- 折算工时
|
||||
sl.service_hours_raw, -- 原始工时
|
||||
sl.course_type, -- 课程类型
|
||||
sl.table_name, -- 台桌名
|
||||
sl.items_sum AS income, -- ⚠️ items_sum 口径
|
||||
sl.is_estimate
|
||||
FROM fdw_etl.v_dwd_assistant_service_log sl
|
||||
LEFT JOIN fdw_etl.v_dim_member dm
|
||||
ON sl.member_id = dm.member_id
|
||||
AND dm.scd2_is_current = 1 -- ⚠️ SCD2 当前记录
|
||||
WHERE sl.assistant_id = %s
|
||||
AND sl.is_trash = false -- ⚠️ 废单排除
|
||||
AND sl.settle_time >= %s -- 月份起始
|
||||
AND sl.settle_time < %s -- 月份结束
|
||||
ORDER BY sl.settle_time DESC
|
||||
LIMIT %s OFFSET %s
|
||||
```
|
||||
|
||||
#### Q4: 任务项扩展字段查询
|
||||
|
||||
```sql
|
||||
-- lastVisitDays: 距上次到店天数
|
||||
SELECT member_id,
|
||||
CURRENT_DATE - MAX(settle_time::date) AS days_since_visit
|
||||
FROM fdw_etl.v_dwd_assistant_service_log
|
||||
WHERE member_id = ANY(%s)
|
||||
AND is_trash = false
|
||||
GROUP BY member_id
|
||||
|
||||
-- balance: 客户储值卡余额
|
||||
-- ⚠️ DQ-7: 通过 member_id JOIN dim_member_card_account
|
||||
SELECT tenant_member_id AS member_id,
|
||||
balance
|
||||
FROM fdw_etl.v_dim_member_card_account
|
||||
WHERE tenant_member_id = ANY(%s)
|
||||
AND scd2_is_current = 1
|
||||
```
|
||||
|
||||
#### Q5: 维客线索查询(TASK-2)
|
||||
|
||||
```sql
|
||||
SELECT id, tag, tag_color, emoji, text, source, description
|
||||
FROM public.member_retention_clue
|
||||
WHERE member_id = %s
|
||||
AND site_id = %s
|
||||
ORDER BY created_at DESC
|
||||
```
|
||||
|
||||
#### Q6: AI 缓存查询(TASK-2)
|
||||
|
||||
```sql
|
||||
-- aiAnalysis: cache_type = 'app4_analysis'
|
||||
-- talkingPoints: cache_type = 'app5_talking_points'
|
||||
SELECT cache_type, cache_value
|
||||
FROM biz.ai_cache
|
||||
WHERE target_id = %s
|
||||
AND site_id = %s
|
||||
AND cache_type IN ('app4_analysis', 'app5_talking_points')
|
||||
```
|
||||
|
||||
#### Q7: 新客/常客列表(PERF-1)
|
||||
|
||||
```sql
|
||||
-- 新客:本月首次服务的客户
|
||||
-- ⚠️ DQ-6: 客户姓名通过 member_id JOIN dim_member
|
||||
WITH month_records AS (
|
||||
SELECT sl.member_id,
|
||||
dm.nickname AS customer_name,
|
||||
COUNT(*) AS service_count,
|
||||
MAX(sl.settle_time) AS last_service
|
||||
FROM fdw_etl.v_dwd_assistant_service_log sl
|
||||
LEFT JOIN fdw_etl.v_dim_member dm
|
||||
ON sl.member_id = dm.member_id AND dm.scd2_is_current = 1
|
||||
WHERE sl.assistant_id = %s
|
||||
AND sl.is_trash = false
|
||||
AND sl.settle_time >= %s AND sl.settle_time < %s
|
||||
GROUP BY sl.member_id, dm.nickname
|
||||
),
|
||||
historical AS (
|
||||
SELECT DISTINCT member_id
|
||||
FROM fdw_etl.v_dwd_assistant_service_log
|
||||
WHERE assistant_id = %s
|
||||
AND is_trash = false
|
||||
AND settle_time < %s -- 本月之前
|
||||
)
|
||||
SELECT mr.member_id, mr.customer_name, mr.service_count, mr.last_service
|
||||
FROM month_records mr
|
||||
LEFT JOIN historical h ON mr.member_id = h.member_id
|
||||
WHERE h.member_id IS NULL -- 新客:历史无记录
|
||||
ORDER BY mr.last_service DESC
|
||||
|
||||
-- 常客:本月服务 ≥ 2 次的客户
|
||||
SELECT mr.member_id, mr.customer_name,
|
||||
SUM(sl.service_hours) AS total_hours,
|
||||
SUM(sl.items_sum) AS total_income, -- ⚠️ items_sum 口径
|
||||
COUNT(*) AS service_count
|
||||
FROM month_records mr
|
||||
JOIN fdw_etl.v_dwd_assistant_service_log sl
|
||||
ON mr.member_id = sl.member_id
|
||||
WHERE mr.service_count >= 2
|
||||
GROUP BY mr.member_id, mr.customer_name
|
||||
ORDER BY total_income DESC
|
||||
```
|
||||
|
||||
### courseTypeClass 枚举映射
|
||||
|
||||
后端根据 `v_dwd_assistant_service_log.course_type` 映射:
|
||||
|
||||
| 原始值 | courseTypeClass | courseType 中文 |
|
||||
|--------|----------------|----------------|
|
||||
| `basic` / `陪打` / `基础课` | `basic` | 基础课 |
|
||||
| `vip` / `包厢` / `包厢课` | `vip` | 包厢课 |
|
||||
| `tip` / `超休` / `激励课` | `tip` | 激励课 |
|
||||
| `recharge` / `充值` | `recharge` | 充值 |
|
||||
| `incentive` / `激励` | `incentive` | 激励 |
|
||||
|
||||
统一不带 `tag-` 前缀(契约要求)。
|
||||
|
||||
### DWD-DOC 强制规则实施位置
|
||||
|
||||
| 规则 | 实施位置 | 具体措施 |
|
||||
|------|---------|---------|
|
||||
| 规则 1: `items_sum` 口径 | `fdw_queries.py` 所有金额查询 | SQL 中使用 `items_sum` 字段,禁止 `consume_money` |
|
||||
| 规则 2: 助教费用拆分 | `fdw_queries.get_salary_calc()` | 使用 `assistant_pd_money` + `assistant_cx_money`,禁止 `service_fee` |
|
||||
| DQ-6: 会员信息 JOIN | `fdw_queries.get_member_info()` + 所有含客户姓名的查询 | `member_id JOIN fdw_etl.v_dim_member WHERE scd2_is_current=1` |
|
||||
| DQ-7: 会员卡 JOIN | `fdw_queries.get_member_balance()` | `member_id JOIN fdw_etl.v_dim_member_card_account WHERE scd2_is_current=1` |
|
||||
| 废单排除 | `fdw_queries` 所有服务记录查询 | `WHERE is_trash = false`,禁止引用 `dwd_assistant_trash_event` |
|
||||
|
||||
|
||||
|
||||
## 正确性属性
|
||||
|
||||
*属性(Property)是一个在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: 收入趋势计算正确性
|
||||
|
||||
*For any* 两个非负收入值 `currentIncome` 和 `prevIncome`,计算 `incomeTrend` 和 `incomeTrendDir` 时:
|
||||
- `incomeTrendDir` 应为 `"up"` 当 `currentIncome >= prevIncome`,否则为 `"down"`
|
||||
- `incomeTrend` 应包含绝对差值 `abs(currentIncome - prevIncome)` 的整数表示
|
||||
- `incomeTrend` 应以 `"↑"` 或 `"↓"` 开头,与 `incomeTrendDir` 一致
|
||||
|
||||
**Validates: Requirements 1.4**
|
||||
|
||||
### Property 2: lastVisitDays 计算正确性
|
||||
|
||||
*For any* 有效日期 `lastVisitDate`(不晚于今天),`lastVisitDays` 应等于 `(current_date - lastVisitDate).days`,且结果为非负整数。
|
||||
|
||||
**Validates: Requirements 1.7**
|
||||
|
||||
### Property 3: 维客线索 tag 净化与 source 枚举
|
||||
|
||||
*For any* 维客线索记录,经过后端处理后:
|
||||
- `tag` 字段不应包含换行符 `\n`(多行标签使用空格分隔)
|
||||
- `source` 字段必须是 `manual`、`ai_consumption`、`ai_note` 三者之一
|
||||
|
||||
**Validates: Requirements 2.4, 2.5**
|
||||
|
||||
### Property 4: courseTypeClass 枚举映射
|
||||
|
||||
*For any* 服务记录的原始课程类型值,经过 `map_course_type_class()` 映射后:
|
||||
- 结果必须是 `basic`、`vip`、`tip`、`recharge`、`incentive` 五者之一
|
||||
- 结果不应包含 `tag-` 前缀
|
||||
|
||||
**Validates: Requirements 2.9, 4.4**
|
||||
|
||||
### Property 5: 列表分页与排序
|
||||
|
||||
*For any* 记录集合和分页参数 `(page, pageSize)`:
|
||||
- 返回的记录数 ≤ `pageSize`
|
||||
- `hasMore` 为 `true` 当且仅当总记录数 > `page * pageSize`
|
||||
- 服务记录在每个 DateGroup 内按时间倒序排列
|
||||
- 备注列表按 `created_at` 倒序排列
|
||||
|
||||
**Validates: Requirements 2.3, 4.2**
|
||||
|
||||
### Property 6: DateGroup 分组正确性
|
||||
|
||||
*For any* 服务记录集合,按日期分组后:
|
||||
- 每个 DateGroup 的 `date` 在结果中唯一
|
||||
- 每个 DateGroup 内的所有记录的日期部分相同
|
||||
- `totalHours` 等于该组内所有记录 `hours` 之和
|
||||
- `totalIncome` 等于该组内所有记录 `income` 之和
|
||||
- DateGroup 列表按日期倒序排列
|
||||
|
||||
**Validates: Requirements 3.3, 4.3**
|
||||
|
||||
### Property 7: incomeItems desc 格式化
|
||||
|
||||
*For any* 费率 `rate`(正数)和工时 `hours`(非负数),生成的 `desc` 字段应包含费率值和工时值,格式为 `"{rate}元/h × {hours}h"`。
|
||||
|
||||
**Validates: Requirements 3.9**
|
||||
|
||||
### Property 8: 月度汇总聚合正确性
|
||||
|
||||
*For any* 服务记录集合,`summary` 的聚合应满足:
|
||||
- `totalCount` 等于记录总数
|
||||
- `totalHours` 等于所有记录折算工时之和
|
||||
- `totalHoursRaw` 等于所有记录原始工时之和
|
||||
- `totalIncome` 等于所有记录收入之和(`items_sum` 口径)
|
||||
|
||||
**Validates: Requirements 4.6**
|
||||
|
||||
### Property 9: Pin/Unpin 状态往返
|
||||
|
||||
*For any* 有效任务,执行 `pin` 后 `isPinned` 应为 `true`,再执行 `unpin` 后 `isPinned` 应恢复为 `false`。即 `unpin(pin(task))` 的 `isPinned` 状态等于原始状态(假设原始为 `false`)。
|
||||
|
||||
**Validates: Requirements 5.1, 5.2, 5.3**
|
||||
|
||||
### Property 10: 权限与数据隔离
|
||||
|
||||
*For any* 请求,若用户状态非 `approved`、缺少 `view_tasks` 权限、或在 `user_assistant_binding` 中无绑定记录,则所有 RNS1.1 端点应返回 HTTP 403。若请求的 `taskId` 不属于当前助教的 `assistant_id`,也应返回 HTTP 403。
|
||||
|
||||
**Validates: Requirements 2.13, 8.1, 8.2, 8.4**
|
||||
|
||||
### Property 11: 前端派生字段计算
|
||||
|
||||
*For any* 客户姓名字符串 `name`(非空),`nameToAvatarColor(name)` 应返回:
|
||||
- `avatarChar` 等于 `name` 的第一个字符
|
||||
- `avatarColor` 为预定义颜色集合中的一个值
|
||||
- 相同 `name` 输入始终产生相同输出(确定性)
|
||||
|
||||
*For any* 余额值 `balance`(非负数),`computeStorageLevel(balance)` 应返回预定义等级之一(如 "非常多"/"较多"/"一般"/"较少"),且等级随余额单调递增。
|
||||
|
||||
*For any* 亲密度分数 `heartScore`(0-10),`computeRelationLevel(heartScore)` 应返回预定义等级之一,且等级随分数单调递增。
|
||||
|
||||
**Validates: Requirements 6.4, 6.5, 7.6**
|
||||
|
||||
### Property 12: 备注 score 输入验证
|
||||
|
||||
*For any* 整数 `score`,若 `score` 在 1-5 范围内,`POST /api/xcx/notes` 应接受并存储;若 `score` 超出范围(<1 或 >5),应返回 422 错误。`score` 为 `null` 时应正常创建备注(score 可选)。
|
||||
|
||||
**Validates: Requirements 6.3**
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 后端错误处理
|
||||
|
||||
| 错误场景 | HTTP 状态码 | 响应 | 触发位置 |
|
||||
|---------|:----------:|------|---------|
|
||||
| 用户未通过审核 | 403 | `{ code: 403, message: "用户未通过审核,无法访问此资源" }` | `require_approved()` |
|
||||
| 用户无 `view_tasks` 权限 | 403 | `{ code: 403, message: "权限不足" }` | `require_permission("view_tasks")` |
|
||||
| 用户未绑定助教身份 | 403 | `{ code: 403, message: "未绑定助教身份" }` | `_get_assistant_id()` |
|
||||
| 任务不属于当前助教 | 403 | `{ code: 403, message: "无权访问该任务" }` | `_verify_task_ownership()` |
|
||||
| 任务不存在 | 404 | `{ code: 404, message: "任务不存在" }` | `get_task_detail()` / `pin_task()` |
|
||||
| score 超出 1-5 范围 | 422 | `{ code: 422, message: "评分必须在 1-5 范围内" }` | `create_note()` |
|
||||
| 无效的 status 参数 | 422 | Pydantic 验证错误 | FastAPI 参数校验 |
|
||||
| year/month 参数无效 | 422 | Pydantic 验证错误 | FastAPI 参数校验 |
|
||||
| FDW 查询超时/失败 | 500 | `{ code: 500, message: "Internal Server Error" }` | `unhandled_exception_handler` |
|
||||
| 数据库连接失败 | 500 | `{ code: 500, message: "Internal Server Error" }` | `unhandled_exception_handler` |
|
||||
|
||||
### FDW 查询容错策略
|
||||
|
||||
任务列表扩展字段(`lastVisitDays`、`balance`、`aiSuggestion`)采用**优雅降级**策略:
|
||||
|
||||
```python
|
||||
# 单个扩展字段查询失败不影响整体响应
|
||||
try:
|
||||
last_visit_map = fdw_queries.get_last_visit_days(conn, site_id, member_ids)
|
||||
except Exception:
|
||||
logger.warning("FDW 查询 lastVisitDays 失败", exc_info=True)
|
||||
last_visit_map = {}
|
||||
```
|
||||
|
||||
核心字段(绩效概览、服务记录)查询失败则直接抛出 500。
|
||||
|
||||
### 前端错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| API 返回 403 | 显示"权限不足"提示,不跳转 |
|
||||
| API 返回 404 | 显示"任务不存在"提示,返回列表页 |
|
||||
| API 返回 500 | 显示"服务器错误"toast |
|
||||
| 月份切换期间网络失败 | 恢复上一月份状态,显示重试提示 |
|
||||
| 扩展字段为 null | 前端隐藏对应 UI 元素(如不显示余额标签) |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 双轨测试方法
|
||||
|
||||
RNS1.1 采用属性测试(Property-Based Testing)+ 单元测试(Unit Testing)双轨并行:
|
||||
|
||||
- **属性测试**:验证计算逻辑、枚举映射、分页排序、聚合等通用规则在所有输入上的正确性
|
||||
- **单元测试**:验证具体的 API 端点行为、权限校验、FDW 查询集成、边界条件
|
||||
|
||||
### 属性测试配置
|
||||
|
||||
- **测试库**:[Hypothesis](https://hypothesis.readthedocs.io/)(Python,项目已使用)
|
||||
- **测试位置**:`tests/` 目录(Monorepo 级属性测试)
|
||||
- **最小迭代次数**:每个属性测试 100 次(`@settings(max_examples=100)`)
|
||||
- **标签格式**:每个测试函数的 docstring 中标注 `Feature: rns1-task-performance-api, Property {N}: {property_text}`
|
||||
- **每个正确性属性由一个属性测试实现**
|
||||
|
||||
### 属性测试清单
|
||||
|
||||
| Property | 测试函数 | 生成器 | 验证逻辑 |
|
||||
|----------|---------|--------|---------|
|
||||
| P1: 收入趋势计算 | `test_income_trend_computation` | `st.floats(min_value=0, max_value=1e6)` × 2 | 方向正确、差值正确、前缀符号一致 |
|
||||
| P2: lastVisitDays 计算 | `test_last_visit_days_computation` | `st.dates(max_value=date.today())` | 天数差 = (today - date).days,非负 |
|
||||
| P3: 维客线索净化 | `test_retention_clue_sanitization` | `st.text()` 生成含 `\n` 的 tag + `st.sampled_from` source | tag 无换行、source 在枚举内 |
|
||||
| P4: courseTypeClass 映射 | `test_course_type_class_mapping` | `st.sampled_from(ALL_COURSE_TYPES)` | 结果在枚举内、无 `tag-` 前缀 |
|
||||
| P5: 分页与排序 | `test_pagination_and_ordering` | `st.lists(st.fixed_dictionaries(...))` + `st.integers(1,10)` page/pageSize | 记录数 ≤ pageSize、hasMore 正确、排序正确 |
|
||||
| P6: DateGroup 分组 | `test_date_group_correctness` | `st.lists(st.fixed_dictionaries({date, hours, income}))` | 日期唯一、组内日期一致、汇总正确 |
|
||||
| P7: incomeItems desc 格式化 | `test_income_item_desc_format` | `st.floats(min_value=0.01)` rate × `st.floats(min_value=0)` hours | 包含费率和工时值 |
|
||||
| P8: 月度汇总聚合 | `test_monthly_summary_aggregation` | `st.lists(st.fixed_dictionaries({hours, hours_raw, income}))` | count/hours/hours_raw/income 聚合正确 |
|
||||
| P9: Pin/Unpin 往返 | `test_pin_unpin_roundtrip` | `st.booleans()` 初始状态 | pin→true、unpin→false、往返恢复 |
|
||||
| P10: 权限隔离 | `test_authorization_enforcement` | `st.sampled_from(INVALID_USER_SCENARIOS)` | 所有场景返回 403 |
|
||||
| P11: 前端派生字段 | `test_frontend_derived_fields` | `st.text(min_size=1)` name + `st.floats(0, 1e6)` balance + `st.floats(0, 10)` heartScore | avatarChar=name[0]、确定性、单调性 |
|
||||
| P12: score 输入验证 | `test_note_score_validation` | `st.integers()` | 1-5 接受、超范围拒绝、null 接受 |
|
||||
|
||||
### 单元测试清单
|
||||
|
||||
| 测试目标 | 测试文件 | 关键用例 |
|
||||
|---------|---------|---------|
|
||||
| TASK-1 端点 | `tests/unit/test_xcx_tasks_v2.py` | 正常返回 TaskListResponse 结构;performance 含 15+ 字段;扩展字段 null 降级 |
|
||||
| TASK-2 端点 | `tests/unit/test_task_detail.py` | 完整详情返回;customerId 正确;serviceRecords ≤ 20 条;notes ≤ 20 条 |
|
||||
| PERF-1 端点 | `tests/unit/test_performance_overview.py` | DateGroup 结构正确;收入档位数据完整;新客/常客列表 |
|
||||
| PERF-2 端点 | `tests/unit/test_performance_records.py` | 分页正确;summary 聚合正确;hasMore 标记 |
|
||||
| PIN/UNPIN | `tests/unit/test_pin_unpin.py` | 响应格式 `{ isPinned: bool }`;404 不存在;403 非本人任务 |
|
||||
| 权限校验 | `tests/unit/test_auth_rns11.py` | 未审核用户 403;无绑定 403;无权限 403 |
|
||||
| FDW 查询 | `tests/unit/test_fdw_queries.py` | DQ-6 JOIN 正确;is_trash 排除;items_sum 口径 |
|
||||
| AI 缓存降级 | `tests/unit/test_ai_cache_fallback.py` | 无缓存返回空;cache_type 映射正确 |
|
||||
| tierCompleted 边界 | `tests/unit/test_tier_completed.py` | tierCompleted=true 时 bonusMoney=0 |
|
||||
|
||||
### 测试执行命令
|
||||
|
||||
```bash
|
||||
# 属性测试(Hypothesis)
|
||||
cd C:\NeoZQYY && pytest tests/ -v -k "rns1_task_performance"
|
||||
|
||||
# 单元测试
|
||||
cd apps/backend && pytest tests/unit/ -v -k "xcx_tasks_v2 or task_detail or performance or pin_unpin or auth_rns11 or fdw_queries"
|
||||
```
|
||||
214
.kiro/specs/rns1-task-performance-api/requirements.md
Normal file
214
.kiro/specs/rns1-task-performance-api/requirements.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# 需求文档 — RNS1.1:任务与绩效接口
|
||||
|
||||
## 简介
|
||||
|
||||
RNS1.1 是 NS1 小程序后端 API 补全项目的第二个子 spec,负责实现助教日常使用频率最高的 4 个核心接口(任务列表扩展、任务详情、绩效概览、绩效明细)、pin/unpin 端点、以及对应的前端适配。本 spec 覆盖助教视角的核心工作流,是助教登录后最先接触的功能集合。
|
||||
|
||||
### 依赖
|
||||
|
||||
- RNS1.0(基础设施与契约重写)必须先完成:全局响应包装中间件、camelCase 转换、重写后的 API 契约
|
||||
- 前端已有 13 个页面(P5.2 交付),当前使用 mock 数据
|
||||
- 后端已有 `xcx_tasks.py`(需扩展)、`xcx_notes.py`、`xcx_auth.py`
|
||||
|
||||
### 来源文档
|
||||
|
||||
- `docs/prd/Neo_Specs/RNS1-split-plan.md` — 拆分计划主文档
|
||||
- `docs/miniprogram-dev/API-contract.md` — API 契约(RNS1.0 T0-5 重写后版本)
|
||||
- `docs/prd/Neo_Specs/storyboard-walkthrough-assistant-view.md` — 助教视角走查报告(GAP-02~22)
|
||||
- `docs/reports/DWD-DOC/` — 金额口径与字段语义权威标杆文档
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Backend**:FastAPI 后端应用,位于 `apps/backend/`
|
||||
- **Miniprogram**:微信小程序前端应用,位于 `apps/miniprogram/`
|
||||
- **TASK_1_API**:任务列表接口 `GET /api/xcx/tasks`,返回任务列表 + 绩效概览
|
||||
- **TASK_2_API**:任务详情接口 `GET /api/xcx/tasks/{taskId}`,返回单个任务的完整详情
|
||||
- **PERF_1_API**:绩效概览接口 `GET /api/xcx/performance`,返回助教当月绩效汇总
|
||||
- **PERF_2_API**:绩效明细接口 `GET /api/xcx/performance/records`,返回按日期分组的服务记录
|
||||
- **PIN_API**:任务置顶/取消置顶接口 `POST /api/xcx/tasks/{id}/pin` 和 `POST /api/xcx/tasks/{id}/unpin`
|
||||
- **PerformanceSummary**:任务列表响应中附带的绩效概览数据结构(15+ 字段)
|
||||
- **DateGroup**:按日期分组的数据结构,包含日期标签、当日汇总、记录列表
|
||||
- **FDW**:PostgreSQL Foreign Data Wrapper,用于从业务库 `zqyy_app` 访问 ETL 库 `etl_feiqiu` 的数据
|
||||
- **items_sum**:DWD-DOC 强制使用的消费金额口径,= `table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money`
|
||||
- **assistant_pd_money**:助教陪打费用(基础课),DWD-DOC 强制规则 2 要求的拆分字段
|
||||
- **assistant_cx_money**:助教超休费用(激励课),DWD-DOC 强制规则 2 要求的拆分字段
|
||||
- **enrichTask**:前端 `task-list` 页面中对原始任务数据进行扩展的函数,补充 `lastVisitDays`/`balance`/`aiSuggestion` 等字段
|
||||
- **buildPerfData**:前端 `task-list` 页面中构建绩效进度条数据的函数,消费 `PerformanceSummary` 的 15+ 字段
|
||||
- **courseTypeClass**:服务记录中课程类型的样式标识,统一使用 `basic`/`vip`/`tip` 枚举(不带 `tag-` 前缀)
|
||||
- **user_assistant_binding**:业务库中用户与助教身份的绑定关系表,用于数据隔离
|
||||
- **ai_cache**:业务库中 AI 分析结果的缓存表,按 `cache_type` 区分不同类型的 AI 输出
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:扩展 TASK-1 任务列表绩效概览(T1-1)
|
||||
|
||||
**用户故事:** 作为助教,我希望在任务列表页面看到完整的绩效进度条(含档位节点、基础/激励课时、升档奖金、收入趋势),以便快速了解本月绩效状态和距升档的差距。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE TASK_1_API SHALL 在响应的 `performance` 字段中返回以下扩展字段:`tierNodes`(档位节点数组,如 `[0, 100, 130, 160, 190, 220]`)、`basicHours`(基础课时)、`bonusHours`(激励课时)、`currentTier`(当前档位索引,0-based)、`nextTierHours`(下一档位工时阈值)、`tierCompleted`(是否已达最高档)、`bonusMoney`(升档奖金,元)、`incomeTrend`(收入趋势描述,如 `"↓368"`)、`incomeTrendDir`(`up` 或 `down`)、`prevMonth`(上月标签,如 `"1月"`)、`currentTierLabel`(当前档位名称,如 `"初级"`)
|
||||
2. THE TASK_1_API SHALL 从 `fdw_etl.v_dws_assistant_salary_calc` 查询当前助教的绩效和档位数据,通过 `user_assistant_binding` 获取 `assistant_id` 进行数据隔离
|
||||
3. THE TASK_1_API SHALL 使用 `items_sum` 口径计算所有收入金额(DWD-DOC 强制规则 1),使用 `assistant_pd_money`(基础课)和 `assistant_cx_money`(激励课)拆分助教费用(DWD-DOC 强制规则 2)
|
||||
4. THE TASK_1_API SHALL 通过对比当月和上月的收入数据计算 `incomeTrend` 和 `incomeTrendDir` 字段
|
||||
5. WHEN `tierCompleted` 为 `true` 时,THE TASK_1_API SHALL 将 `bonusMoney` 设为 0,`nextTierHours` 设为当前档位工时阈值
|
||||
|
||||
#### 1.2 任务项扩展字段(GAP-03)
|
||||
|
||||
6. THE TASK_1_API SHALL 为每个任务 item 返回以下可选扩展字段:`lastVisitDays`(距上次到店天数,integer)、`balance`(客户余额,number,元)、`aiSuggestion`(AI 建议摘要,string)
|
||||
7. THE TASK_1_API SHALL 从 `fdw_etl.dwd.dwd_settlement_head` 查询客户最后到店日期,计算 `lastVisitDays`(当前日期与 `MAX(settle_time)` 的天数差)
|
||||
8. THE TASK_1_API SHALL 从 `fdw_etl.dwd.dim_member_card_account` 查询客户储值卡余额作为 `balance` 字段值
|
||||
9. THE TASK_1_API SHALL 从 `biz.ai_cache` 查询 `cache_type` 对应的 AI 建议摘要作为 `aiSuggestion` 字段值
|
||||
10. IF 某个扩展字段的数据源查询失败或无数据,THEN THE TASK_1_API SHALL 对该字段返回 `null`,不影响其他字段和整体响应
|
||||
|
||||
### 需求 2:实现 TASK-2 任务详情完整版(T1-2)
|
||||
|
||||
**用户故事:** 作为助教,我希望点击任务后能看到完整的任务详情(包括服务记录、维客线索、AI 分析、备注),以便全面了解客户情况并制定跟进策略。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE TASK_2_API SHALL 返回完整的任务详情响应,包含基础信息、维客线索(`retentionClues`)、话术参考(`talkingPoints`)、服务记录摘要(`serviceSummary`)、服务记录列表(`serviceRecords`)、AI 分析(`aiAnalysis`)、备注列表(`notes`)
|
||||
2. THE TASK_2_API SHALL 在响应中包含 `customerId` 字段(客户唯一 ID),供前端跳转 chat 和 customer-service-records 页面使用
|
||||
3. WHEN 请求任务详情时,THE TASK_2_API SHALL 对服务记录(`serviceRecords`)和备注(`notes`)各返回最多 20 条,按时间倒序排列,支持前端懒加载
|
||||
|
||||
#### 2.2 维客线索格式统一(GAP-06~07)
|
||||
|
||||
4. THE TASK_2_API SHALL 返回维客线索的 `tag` 字段为纯文本字符串(不含换行符 `\n`),多行标签使用空格分隔
|
||||
5. THE TASK_2_API SHALL 返回维客线索的 `source` 字段为以下枚举值之一:`manual`(手动创建)、`ai_consumption`(AI 消费分析生成)、`ai_note`(AI 备注分析生成)
|
||||
6. THE TASK_2_API SHALL 从 `public.member_retention_clue` 查询维客线索数据,按 `created_at` 倒序排列
|
||||
|
||||
#### 2.3 AI 分析 cache_type 映射(GAP-08)
|
||||
|
||||
7. THE TASK_2_API SHALL 从 `biz.ai_cache` 查询 AI 分析数据,使用以下 `cache_type` 映射:`aiAnalysis.summary` 来自 `app4_analysis`,`talkingPoints` 来自 `app5_talking_points`
|
||||
8. IF `biz.ai_cache` 中无对应 `cache_type` 的缓存记录,THEN THE TASK_2_API SHALL 对 `aiAnalysis` 返回 `{ summary: "", suggestions: [] }`,对 `talkingPoints` 返回空数组
|
||||
|
||||
#### 2.4 服务记录字段(GAP-06)
|
||||
|
||||
9. THE TASK_2_API SHALL 为每条服务记录返回 `courseTypeClass` 字段,使用统一枚举值:`basic`(基础课)、`vip`(包厢课)、`tip`(激励课)、`recharge`(充值)、`incentive`(激励),不带 `tag-` 前缀
|
||||
10. THE TASK_2_API SHALL 为每条服务记录返回可选字段 `recordType`(`course` 或 `recharge`)和 `isEstimate`(是否预估数据,boolean)
|
||||
11. THE TASK_2_API SHALL 使用 `items_sum` 口径计算服务记录中的 `income` 字段(DWD-DOC 强制规则 1)
|
||||
12. THE TASK_2_API SHALL 使用 `dwd_assistant_service_log_ex.is_trash` 排除废单记录
|
||||
|
||||
#### 2.5 权限与数据隔离
|
||||
|
||||
13. THE TASK_2_API SHALL 验证请求的 `taskId` 属于当前登录助教(通过 `user_assistant_binding` 获取 `assistant_id`,校验 `coach_tasks.assistant_id` 匹配)
|
||||
14. IF 请求的 `taskId` 不属于当前助教,THEN THE TASK_2_API SHALL 返回 HTTP 403 `{ code: 403, message: "无权访问该任务" }`
|
||||
|
||||
### 需求 3:实现 PERF-1 绩效概览(T1-3)
|
||||
|
||||
**用户故事:** 作为助教,我希望查看本月绩效概览(包括收入明细、档位进度、服务记录按日期分组、新客和常客列表),以便了解本月工作成果和收入构成。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE PERF_1_API SHALL 接受 `year` 和 `month` 查询参数,返回指定月份的绩效概览数据
|
||||
2. THE PERF_1_API SHALL 通过 `user_assistant_binding` 获取当前助教的 `assistant_id`,仅查询该助教自己的绩效数据
|
||||
|
||||
#### 3.2 本月服务记录 DateGroup 结构(GAP-12)
|
||||
|
||||
3. THE PERF_1_API SHALL 将 `thisMonthRecords` 以 DateGroup 结构返回:每组包含 `date`(日期标签,如 `"2月7日"`)、`totalHours`(当日总工时,格式化字符串)、`totalIncome`(当日总收入,格式化字符串)、`records`(记录列表)
|
||||
4. THE PERF_1_API SHALL 为 DateGroup 中每条记录返回以下字段:`customerName`、`avatarChar`(姓氏首字)、`avatarColor`(头像渐变色)、`timeRange`(时间段,如 `"20:00-22:00"`)、`hours`(工时,格式化字符串)、`courseType`(课程类型,如 `"基础课"`)、`courseTypeClass`(样式标识:`basic`/`vip`/`tip`,不带 `tag-` 前缀)、`location`(台桌/包厢名)、`income`(收入,格式化字符串)
|
||||
5. THE PERF_1_API SHALL 从 `fdw_etl.v_dwd_assistant_service_log` 查询服务记录,按 `settle_time` 日期分组,每组内按时间倒序排列
|
||||
|
||||
#### 3.3 收入档位数据(GAP-13)
|
||||
|
||||
6. THE PERF_1_API SHALL 返回收入档位数据:`currentTier`(当前档,含 `basicRate` 和 `incentiveRate`)、`nextTier`(下一档,含 `basicRate` 和 `incentiveRate`)、`upgradeHoursNeeded`(距升档所需工时,number)、`upgradeBonus`(升档奖金,number,元)
|
||||
7. THE PERF_1_API SHALL 从 `fdw_etl.v_dws_assistant_salary_calc` 查询档位和费率数据
|
||||
|
||||
#### 3.4 上月收入与收入明细(GAP-14~15)
|
||||
|
||||
8. THE PERF_1_API SHALL 返回 `lastMonthIncome` 字段(上月收入,格式化字符串,如 `"¥16,880"`)
|
||||
9. THE PERF_1_API SHALL 为 `incomeItems` 每项返回 `desc` 字段(费率×工时的拆分描述,如 `"80元/h × 75h"`),由后端根据费率和工时数据计算生成
|
||||
10. THE PERF_1_API SHALL 使用 `items_sum` 口径计算所有收入金额(DWD-DOC 强制规则 1),使用 `assistant_pd_money` 和 `assistant_cx_money` 拆分助教费用(DWD-DOC 强制规则 2)
|
||||
|
||||
#### 3.5 新客与常客列表(GAP-16)
|
||||
|
||||
11. THE PERF_1_API SHALL 为 `newCustomers` 每项返回 `lastService`(最后服务日期,如 `"2月7日"`)和 `count`(服务次数,number)字段
|
||||
12. THE PERF_1_API SHALL 为 `regularCustomers` 每项返回 `hours`(总工时,number)和 `income`(总收入,格式化字符串)字段
|
||||
13. THE PERF_1_API SHALL 通过 `member_id` JOIN `fdw_etl.dwd.dim_member`(取 `scd2_is_current=1`)获取客户姓名(DWD-DOC 强制规则 DQ-6),禁止直接使用 `member_phone` 或 `member_name`
|
||||
|
||||
### 需求 4:实现 PERF-2 绩效明细(T1-4)
|
||||
|
||||
**用户故事:** 作为助教,我希望查看指定月份的绩效明细(按日期分组的服务记录列表),以便回顾每天的工作详情。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE PERF_2_API SHALL 接受 `year`、`month`、`page`、`pageSize` 查询参数,返回指定月份的绩效明细数据
|
||||
2. THE PERF_2_API SHALL 每页返回最多 20 条记录(默认 `pageSize=20`),按日期分组为 `dateGroups` 结构,并返回 `hasMore` 标记指示是否有更多数据
|
||||
|
||||
#### 4.2 按日期分组(GAP-19~20)
|
||||
|
||||
3. THE PERF_2_API SHALL 将服务记录按日期分组,每组包含 `date`(日期标签)、`totalHours`(当日总工时)、`totalIncome`(当日总收入)、`records`(记录列表)
|
||||
4. THE PERF_2_API SHALL 为每条记录返回 `courseTypeClass` 字段,使用统一枚举值 `basic`/`vip`/`tip`(不带 `tag-` 前缀)
|
||||
5. THE PERF_2_API SHALL 不返回 `avatarChar` 和 `avatarColor` 字段(前端通过 `nameToAvatarColor()` 工具函数从 `customerName` 自行计算)
|
||||
|
||||
#### 4.3 月度汇总
|
||||
|
||||
6. THE PERF_2_API SHALL 返回月度汇总数据 `summary`:`totalCount`(总记录数)、`totalHours`(总工时)、`totalHoursRaw`(原始工时,未折算)、`totalIncome`(总收入)
|
||||
7. THE PERF_2_API SHALL 使用 `items_sum` 口径计算 `totalIncome` 和每条记录的 `income`(DWD-DOC 强制规则 1)
|
||||
8. THE PERF_2_API SHALL 通过 `member_id` JOIN `fdw_etl.dwd.dim_member`(取 `scd2_is_current=1`)获取客户姓名(DWD-DOC 强制规则 DQ-6)
|
||||
|
||||
### 需求 5:实现 pin/unpin API 端点(T1-5)
|
||||
|
||||
**用户故事:** 作为助教,我希望将重要任务置顶或取消置顶,以便优先处理关键客户的跟进任务。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Backend SHALL 实现 `POST /api/xcx/tasks/{taskId}/pin` 端点,将指定任务的 `is_pinned` 字段设为 `true`
|
||||
2. THE Backend SHALL 实现 `POST /api/xcx/tasks/{taskId}/unpin` 端点,将指定任务的 `is_pinned` 字段设为 `false`
|
||||
3. WHEN pin 或 unpin 操作成功时,THE PIN_API SHALL 返回 `{ isPinned: true }` 或 `{ isPinned: false }`
|
||||
4. THE PIN_API SHALL 验证请求的 `taskId` 属于当前登录助教(通过 `user_assistant_binding` 校验),不属于时返回 HTTP 403
|
||||
5. IF 请求的 `taskId` 不存在,THEN THE PIN_API SHALL 返回 HTTP 404 `{ code: 404, message: "任务不存在" }`
|
||||
6. THE Miniprogram SHALL 在 `services/api.ts` 中新增 `pinTask(taskId: string)` 和 `unpinTask(taskId: string)` 函数,分别调用 pin 和 unpin 端点
|
||||
7. WHEN pin/unpin API 调用成功后,THE Miniprogram SHALL 更新本地任务列表状态(将任务移入/移出置顶分组),无需重新请求完整任务列表
|
||||
|
||||
### 需求 6:前端适配 — 任务页面(T1-6 任务部分)
|
||||
|
||||
**用户故事:** 作为助教,我希望任务列表和任务详情页面能正确展示后端返回的真实数据(替代当前的 mock 数据),以便看到真实的客户信息和绩效状态。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 6.1 createNote 补充用户手动评分参数(GAP-05)
|
||||
|
||||
1. THE Miniprogram SHALL 修改 `services/api.ts` 中 `createNote` 函数的签名,增加可选参数 `manualScore?: number`(用户手动评分,1-5 星)
|
||||
2. WHEN 用户在备注弹窗中提交备注时,THE Miniprogram SHALL 将 `manualScore` 字段一并传递给 `POST /api/xcx/notes` 端点
|
||||
3. THE Backend SHALL 修改 `POST /api/xcx/notes` 端点,接受请求体中的可选 `manualScore` 字段(number,1-5),并存入 `biz.notes.score` 列。注意:此字段为用户手动评分(再次服务意愿 + 再来店可能性),与 AI 应用 6 的 `aiScore`(1-10 分,展示用)语义不同
|
||||
|
||||
#### 6.2 storageLevel/relationLevel 前端计算(GAP-11)
|
||||
|
||||
4. THE Miniprogram SHALL 在 task-detail 页面根据后端返回的 `balance` 字段值,在前端本地计算 `storageLevel`(储值等级,如 "非常多"/"较多"/"一般"/"较少")
|
||||
5. THE Miniprogram SHALL 在 task-detail 页面根据后端返回的 `heartScore` 字段值(0-10 范围),在前端本地计算 `relationLevel`、`relationLevelText`、`relationColor`,阈值遵循 P6 AC3 四级映射(>8.5 / >7 / >5 / ≤5)
|
||||
|
||||
### 需求 7:前端适配 — 绩效页面(T1-6 绩效部分)
|
||||
|
||||
**用户故事:** 作为助教,我希望绩效概览和绩效明细页面支持月份切换,以便查看历史月份的绩效数据。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
#### 7.1 绩效概览月份切换(F8, GAP-18)
|
||||
|
||||
1. THE Miniprogram SHALL 在 performance 页面添加月份切换控件(左右箭头 + 月份标签),允许用户在当前月和历史月份之间切换
|
||||
2. WHEN 用户切换月份时,THE Miniprogram SHALL 使用新的 `year`/`month` 参数重新调用 `fetchPerformanceOverview` 接口,加载对应月份的绩效数据
|
||||
3. THE Miniprogram SHALL 在月份切换期间显示加载状态,防止用户重复操作
|
||||
|
||||
#### 7.2 绩效明细月份切换重置分页(F9, GAP-21)
|
||||
|
||||
4. WHEN 用户在 performance-records 页面切换月份时,THE Miniprogram SHALL 将 `page` 重置为 1,清空已加载的记录列表,重新从第一页加载
|
||||
5. THE Miniprogram SHALL 修复 `switchMonth()` 函数中 `page` 未重置的 Bug
|
||||
|
||||
#### 7.3 avatarChar/avatarColor 前端计算(GAP-19 决策)
|
||||
|
||||
6. THE Miniprogram SHALL 在 performance 和 performance-records 页面,使用 `nameToAvatarColor()` 工具函数从 `customerName` 计算 `avatarChar`(姓氏首字)和 `avatarColor`(头像渐变色),不依赖后端返回这两个字段
|
||||
|
||||
### 需求 8:全局约束与数据隔离
|
||||
|
||||
**用户故事:** 作为系统管理员,我希望所有任务和绩效接口都遵循统一的权限控制和数据隔离规则,以确保每位助教只能访问自己的数据。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE Backend SHALL 对所有 RNS1.1 接口(TASK-1、TASK-2、PERF-1、PERF-2、PIN/UNPIN)执行 `require_approved()` 权限检查,确保用户状态为 `approved`
|
||||
2. THE Backend SHALL 对所有 RNS1.1 接口验证用户具有 `view_tasks` 权限
|
||||
3. THE Backend SHALL 通过 `user_assistant_binding` 表获取当前用户对应的 `assistant_id`,所有数据查询均以该 `assistant_id` 作为过滤条件
|
||||
4. IF 当前用户在 `user_assistant_binding` 中无绑定记录,THEN THE Backend SHALL 返回 HTTP 403 `{ code: 403, message: "未绑定助教身份" }`
|
||||
5. THE Backend SHALL 对所有涉及金额的字段统一使用 `items_sum` 口径(DWD-DOC 强制规则 1),禁止使用 `consume_money`
|
||||
6. THE Backend SHALL 对所有涉及助教费用的字段使用 `assistant_pd_money`(陪打/基础课)+ `assistant_cx_money`(超休/激励课)拆分(DWD-DOC 强制规则 2),禁止使用 `service_fee`
|
||||
7. THE Backend SHALL 对所有涉及会员信息的查询通过 `member_id` LEFT JOIN `fdw_etl.dwd.dim_member`(取 `scd2_is_current=1`)获取姓名和手机号(DWD-DOC 强制规则 DQ-6),禁止直接使用 `settlement_head.member_phone` 或 `member_name`
|
||||
8. THE Backend SHALL 使用 `dwd_assistant_service_log_ex.is_trash` 排除废单记录,禁止使用已废弃的 `dwd_assistant_trash_event` 表
|
||||
168
.kiro/specs/rns1-task-performance-api/tasks.md
Normal file
168
.kiro/specs/rns1-task-performance-api/tasks.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Implementation Plan: RNS1.1 任务与绩效接口
|
||||
|
||||
## Overview
|
||||
|
||||
基于 design.md 的 7 个组件结构,增量扩展现有后端路由和服务层,新增 FDW 查询封装、绩效服务、Pydantic Schema,并完成前端适配。所有 FDW 查询集中在 `fdw_queries.py`,服务层分为 `task_manager.py`(扩展)和 `performance_service.py`(新增)。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Pydantic Schema 定义(组件 6)
|
||||
- [x] 1.1 扩展 `apps/backend/app/schemas/xcx_tasks.py`,新增 `PerformanceSummary`(15+ 字段:tierNodes, basicHours, bonusHours, currentTier, nextTierHours, tierCompleted, bonusMoney, incomeTrend, incomeTrendDir, prevMonth, currentTierLabel 等)、`TaskItem`(扩展版,含 lastVisitDays, balance, aiSuggestion 可选字段)、`TaskListResponse`(items + total + page + pageSize + performance)
|
||||
- 新增 `RetentionClue`、`ServiceRecord`(含 courseTypeClass 枚举)、`AiAnalysis`、`NoteItem`(含 score 可选字段)、`ServiceSummary`、`TaskDetailResponse` 模型
|
||||
- _Requirements: 1.1, 1.6, 2.1, 2.2, 2.4, 2.9_
|
||||
- [x] 1.2 新建 `apps/backend/app/schemas/xcx_performance.py`,定义 `DateGroupRecord`(含可选 avatarChar/avatarColor)、`DateGroup`、`TierInfo`、`IncomeItem`(含 desc)、`CustomerSummary`、`NewCustomer`、`RegularCustomer`、`PerformanceOverviewResponse` 模型
|
||||
- 定义 `RecordsSummary`(totalCount, totalHours, totalHoursRaw, totalIncome)、`PerformanceRecordsResponse`(summary + dateGroups + hasMore)模型
|
||||
- _Requirements: 3.1, 3.3, 3.4, 3.5, 4.1, 4.2, 4.3_
|
||||
|
||||
- [x] 2. FDW 查询封装服务(组件 3 — 新增 fdw_queries.py)
|
||||
- [x] 2.1 新建 `apps/backend/app/services/fdw_queries.py`,实现 `_fdw_context(conn, site_id)` 上下文管理器(BEGIN + SET LOCAL app.current_site_id)和 `get_member_info(conn, site_id, member_ids)` 批量查询会员信息
|
||||
- ⚠️ DQ-6:通过 member_id JOIN fdw_etl.v_dim_member,取 scd2_is_current=1
|
||||
- _Requirements: 8.7_
|
||||
- [x] 2.2 实现 `get_member_balance(conn, site_id, member_ids)` 批量查询会员储值卡余额
|
||||
- ⚠️ DQ-7:通过 member_id JOIN fdw_etl.v_dim_member_card_account,取 scd2_is_current=1
|
||||
- _Requirements: 1.8, 8.7_
|
||||
- [x] 2.3 实现 `get_last_visit_days(conn, site_id, member_ids)` 批量查询客户距上次到店天数
|
||||
- 来源:fdw_etl.v_dwd_assistant_service_log,WHERE is_trash = false
|
||||
- _Requirements: 1.7_
|
||||
- [x] 2.4 实现 `get_salary_calc(conn, site_id, assistant_id, year, month)` 查询助教绩效/档位/收入数据
|
||||
- ⚠️ DWD-DOC 规则 1:收入使用 items_sum 口径
|
||||
- ⚠️ DWD-DOC 规则 2:费用使用 assistant_pd_money + assistant_cx_money
|
||||
- _Requirements: 1.2, 1.3, 3.7, 8.5, 8.6_
|
||||
- [x] 2.5 实现 `get_service_records(conn, site_id, assistant_id, year, month, limit, offset)` 查询助教服务记录明细
|
||||
- ⚠️ 废单排除:WHERE is_trash = false;DQ-6:客户姓名通过 member_id JOIN dim_member
|
||||
- _Requirements: 2.11, 2.12, 3.5, 4.7, 4.8, 8.8_
|
||||
- [x] 2.6 实现 `get_service_records_for_task(conn, site_id, assistant_id, member_id, limit)` 查询特定客户的服务记录(TASK-2 用)
|
||||
- _Requirements: 2.1, 2.3_
|
||||
|
||||
|
||||
- [x] 3. Checkpoint — Schema 与 FDW 查询层验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 4. task_manager 服务扩展(组件 4 — 扩展现有 task_manager.py)
|
||||
- [x] 4.1 在 `apps/backend/app/services/task_manager.py` 中实现 `get_task_list_v2(user_id, site_id, status, page, page_size)`
|
||||
- 逻辑:_get_assistant_id() → 查询 coach_tasks 带分页 → fdw_queries 批量获取会员信息/余额/lastVisitDays → fdw_queries.get_salary_calc() 获取绩效概览 → 查询 ai_cache 获取 aiSuggestion → 组装 TaskListResponse
|
||||
- 扩展字段(lastVisitDays/balance/aiSuggestion)采用优雅降级:单个查询失败返回 null,不影响整体响应
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.10_
|
||||
- [x] 4.2 在 `apps/backend/app/services/task_manager.py` 中实现 `get_task_detail(task_id, user_id, site_id)`
|
||||
- 逻辑:_get_assistant_id() + _verify_task_ownership() 权限校验 → 查询 coach_tasks 基础信息 → 查询 member_retention_clue 维客线索 → 查询 ai_cache(app5_talking_points → talkingPoints, app4_analysis → aiAnalysis)→ fdw_queries.get_service_records_for_task() 服务记录(最多 20 条)→ 查询 notes(最多 20 条)→ 组装 TaskDetailResponse
|
||||
- tag 字段净化:去除换行符 \n,多行标签使用空格分隔
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9, 2.10, 2.11, 2.12, 2.13, 2.14_
|
||||
- [x] 4.3 Write property test: 收入趋势计算正确性
|
||||
- **Property 1: 收入趋势计算正确性**
|
||||
- 生成器:st.floats(min_value=0, max_value=1e6) × 2
|
||||
- 验证:incomeTrendDir 方向正确、差值正确、前缀符号一致
|
||||
- **Validates: Requirements 1.4**
|
||||
- [x] 4.4 Write property test: lastVisitDays 计算正确性
|
||||
- **Property 2: lastVisitDays 计算正确性**
|
||||
- 生成器:st.dates(max_value=date.today())
|
||||
- 验证:天数差 = (today - date).days,非负
|
||||
- **Validates: Requirements 1.7**
|
||||
- [x] 4.5 Write property test: 维客线索 tag 净化与 source 枚举
|
||||
- **Property 3: 维客线索 tag 净化与 source 枚举**
|
||||
- 生成器:st.text() 生成含 \n 的 tag + st.sampled_from source
|
||||
- 验证:tag 无换行、source 在枚举内
|
||||
- **Validates: Requirements 2.4, 2.5**
|
||||
- [x] 4.6 Write property test: courseTypeClass 枚举映射
|
||||
- **Property 4: courseTypeClass 枚举映射**
|
||||
- 生成器:st.sampled_from(ALL_COURSE_TYPES)
|
||||
- 验证:结果在 {basic, vip, tip, recharge, incentive} 内、无 tag- 前缀
|
||||
- **Validates: Requirements 2.9, 4.4**
|
||||
|
||||
- [x] 5. performance_service 服务(组件 5 — 新增 performance_service.py)
|
||||
- [x] 5.1 新建 `apps/backend/app/services/performance_service.py`,实现 `get_overview(user_id, site_id, year, month)`
|
||||
- 逻辑:获取 assistant_id → fdw_queries.get_salary_calc() 档位/收入/费率 → fdw_queries.get_service_records() 按日期分组为 DateGroup → 聚合新客/常客列表 → 计算 incomeItems(含 desc 费率描述)→ 查询上月收入 lastMonthIncome
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13_
|
||||
- [x] 5.2 实现 `get_records(user_id, site_id, year, month, page, page_size)`
|
||||
- 逻辑:获取 assistant_id → fdw_queries.get_service_records() 带分页 → 按日期分组为 dateGroups → 计算 summary 汇总 → 返回 { summary, dateGroups, hasMore }
|
||||
- PERF-2 不返回 avatarChar/avatarColor(前端自行计算)
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8_
|
||||
- [x] 5.3 Write property test: 分页与排序
|
||||
- **Property 5: 列表分页与排序**
|
||||
- 生成器:st.lists(st.fixed_dictionaries(...)) + st.integers(1,10) page/pageSize
|
||||
- 验证:记录数 ≤ pageSize、hasMore 正确、排序正确
|
||||
- **Validates: Requirements 2.3, 4.2**
|
||||
- [x] 5.4 Write property test: DateGroup 分组正确性
|
||||
- **Property 6: DateGroup 分组正确性**
|
||||
- 生成器:st.lists(st.fixed_dictionaries({date, hours, income}))
|
||||
- 验证:日期唯一、组内日期一致、汇总正确、按日期倒序
|
||||
- **Validates: Requirements 3.3, 4.3**
|
||||
- [x] 5.5 Write property test: incomeItems desc 格式化
|
||||
- **Property 7: incomeItems desc 格式化**
|
||||
- 生成器:st.floats(min_value=0.01) rate × st.floats(min_value=0) hours
|
||||
- 验证:desc 包含费率值和工时值,格式为 "{rate}元/h × {hours}h"
|
||||
- **Validates: Requirements 3.9**
|
||||
- [x] 5.6 Write property test: 月度汇总聚合正确性
|
||||
- **Property 8: 月度汇总聚合正确性**
|
||||
- 生成器:st.lists(st.fixed_dictionaries({hours, hours_raw, income}))
|
||||
- 验证:totalCount/totalHours/totalHoursRaw/totalIncome 聚合正确
|
||||
- **Validates: Requirements 4.6**
|
||||
|
||||
- [x] 6. Checkpoint — 服务层验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 7. xcx_tasks Router 扩展(组件 1)
|
||||
- [x] 7.1 修改 `apps/backend/app/routers/xcx_tasks.py`:将 `GET /api/xcx/tasks` 响应从 `list[TaskListItem]` 改为 `TaskListResponse`(含 items + performance + 分页),新增 status/page/pageSize 查询参数,调用 `task_manager.get_task_list_v2()`
|
||||
- _Requirements: 1.1, 1.2_
|
||||
- [x] 7.2 在 `routers/xcx_tasks.py` 中新增 `GET /api/xcx/tasks/{task_id}` 端点,调用 `task_manager.get_task_detail()`,含 require_approved() 权限校验
|
||||
- _Requirements: 2.1, 2.13, 2.14_
|
||||
- [x] 7.3 修改 `routers/xcx_tasks.py` 中 `POST .../pin` 和 `POST .../unpin` 端点,响应对齐契约格式 `{ isPinned: bool }`
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
|
||||
- [x] 7.4 修改 `POST /api/xcx/notes` 端点(`xcx_notes.py`),接受请求体中的可选 `score` 字段(number, 1-5),存入 `biz.coach_notes.score` 列;超出 1-5 范围返回 422
|
||||
- _Requirements: 6.3_
|
||||
- [x] 7.5 Write property test: Pin/Unpin 状态往返
|
||||
- **Property 9: Pin/Unpin 状态往返**
|
||||
- 生成器:st.booleans() 初始状态
|
||||
- 验证:pin→true、unpin→false、往返恢复
|
||||
- **Validates: Requirements 5.1, 5.2, 5.3**
|
||||
- [x] 7.6 Write property test: 权限与数据隔离
|
||||
- **Property 10: 权限与数据隔离**
|
||||
- 生成器:st.sampled_from(INVALID_USER_SCENARIOS)
|
||||
- 验证:未审核/无绑定/无权限/非本人任务 → 所有端点返回 403
|
||||
- **Validates: Requirements 2.13, 8.1, 8.2, 8.4**
|
||||
- [x] 7.7 Write property test: 备注 score 输入验证
|
||||
- **Property 12: 备注 score 输入验证**
|
||||
- 生成器:st.integers()
|
||||
- 验证:1-5 接受、超范围拒绝 422、null 接受
|
||||
- **Validates: Requirements 6.3**
|
||||
|
||||
- [x] 8. xcx_performance Router(组件 2 — 新增)
|
||||
- [x] 8.1 新建 `apps/backend/app/routers/xcx_performance.py`,实现 `GET /api/xcx/performance` 端点(接受 year/month 参数),调用 `performance_service.get_overview()`,含 require_approved() 权限校验
|
||||
- _Requirements: 3.1, 3.2_
|
||||
- [x] 8.2 在 `routers/xcx_performance.py` 中实现 `GET /api/xcx/performance/records` 端点(接受 year/month/page/pageSize 参数),调用 `performance_service.get_records()`
|
||||
- _Requirements: 4.1, 4.2_
|
||||
- [x] 8.3 修改 `apps/backend/app/main.py`,导入并注册 `xcx_performance.router`
|
||||
- _Requirements: 3.1, 4.1_
|
||||
|
||||
- [x] 9. Checkpoint — 后端路由层验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 10. 前端适配(组件 7)
|
||||
- [x] 10.1 修改 `apps/miniprogram/miniprogram/services/api.ts`:新增 `pinTask()`、`unpinTask()`、`fetchTaskDetail()`、`fetchPerformanceOverview(year, month)`、`fetchPerformanceRecords(year, month, page)` 函数;`createNote()` 增加可选 `score` 参数
|
||||
- _Requirements: 5.6, 6.1, 6.2, 7.1_
|
||||
- [x] 10.2 修改 `apps/miniprogram/miniprogram/pages/task-list/task-list.ts`:消费 `TaskListResponse` 新结构(items + performance),`buildPerfData()` 使用 15+ 字段绩效数据,移除 mock 数据
|
||||
- _Requirements: 6.4, 6.5_
|
||||
- [x] 10.3 修改 task-detail 页面:调用 `fetchTaskDetail()`;根据 `balance` 前端本地计算 `storageLevel`,根据 `heartScore` 计算 `relationLevel`/`relationLevelText`/`relationColor`
|
||||
- _Requirements: 6.4, 6.5_
|
||||
- [x] 10.4 修改 `apps/miniprogram/miniprogram/pages/performance/performance.ts`:添加月份切换控件(左右箭头 + 月份标签),实现 `switchMonth()` 调用 `fetchPerformanceOverview`,含加载状态管理
|
||||
- _Requirements: 7.1, 7.2, 7.3_
|
||||
- [x] 10.5 修改 `apps/miniprogram/miniprogram/pages/performance-records/performance-records.ts`:修复 `switchMonth()` 中 page 未重置的 Bug,切换月份时清空记录列表并重新加载(page 重置为 1)
|
||||
- _Requirements: 7.4, 7.5_
|
||||
- [x] 10.6 在 performance 和 performance-records 页面中,使用 `nameToAvatarColor()` 从 `customerName` 计算 `avatarChar`/`avatarColor`,不依赖后端
|
||||
- _Requirements: 7.6_
|
||||
- [x] 10.7 Write property test: 前端派生字段计算
|
||||
- **Property 11: 前端派生字段计算**
|
||||
- 生成器:st.text(min_size=1) name + st.floats(0, 1e6) balance + st.floats(0, 10) heartScore
|
||||
- 验证:avatarChar=name[0]、确定性、storageLevel/relationLevel 单调性
|
||||
- **Validates: Requirements 6.4, 6.5, 7.6**
|
||||
|
||||
- [x] 11. Final checkpoint — 全量验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- 组件 3(fdw_queries.py)是所有 FDW 查询的集中封装,组件 4/5 通过调用它访问 ETL 数据
|
||||
- 组件 4 扩展现有 task_manager.py(不新建 task_perf_service.py),组件 5 新建 performance_service.py
|
||||
- Schema 文件命名:`xcx_tasks.py`(扩展)+ `xcx_performance.py`(新增),与 design.md 一致
|
||||
- 所有 12 个正确性属性(P1-P12)均有对应的属性测试任务
|
||||
- DWD-DOC 强制规则在 fdw_queries.py 中统一实施:items_sum 口径、费用拆分、DQ-6/DQ-7 JOIN、废单排除
|
||||
Reference in New Issue
Block a user