Files
Neo-ZQYY/.kiro/specs/rns1-board-apis/design.md

841 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计文档 — RNS1.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_recharge_commission` | BOARD-1 sv 维度(助教储值提成明细,取 recharge_amount + commission_amount |
| `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_recharge_commission`(助教储值提成明细表,取 recharge_amount + commission_amount`v_dws_assistant_monthly_summary` 口径错误M15 修复)
- 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` 结尾的 keycamelCase 格式)。
**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 # 单元测试
```