Files
Neo-ZQYY/.kiro/specs/gift-card-breakdown/design.md

482 lines
20 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.
# 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["ETLFinanceRechargeTask"]
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_accountmock 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);
```