feat: batch update - gift card breakdown spec, backend APIs, miniprogram pages, ETL finance recharge, docs & migrations

This commit is contained in:
Neo
2026-03-20 01:43:48 +08:00
parent 075caf067f
commit 79f9a0e1da
437 changed files with 118603 additions and 976 deletions

View File

@@ -0,0 +1 @@
{"specId": "4b6736e7-40fc-40a9-82f7-809f80253fe2", "workflowType": "requirements-first", "specType": "feature"}

View 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["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);
```

View 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`(总额与细分之和恒等)

View 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
- 设计文档使用 PythonETL、SQLDDL/查询、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` 常量