chore: 文档与 IDE 配置整理
- .kiro/specs/ → docs/specs/(41 个历史需求 spec 迁移,移除 .config.kiro) - CLAUDE.md 三层拆分:根文件精简 + apps/backend/CLAUDE.md + .claude/commands/ - 新增 /spec-close、/pre-change 两个工作流命令 - DDL 基线刷新(从测试库重新导出 11 个文件,dws 35→38 表,biz 18→21 表) - BD_Manual → BD_manual 命名统一(48 个文件) - 修复 3 处文档与数据库不一致(auth.users.status 默认值、scheduled_tasks 字段、RLS 视图数) - 新增 BD_manual_public_rbac_tables.md(public schema 8 张 RBAC/工作流表) - 合并 biz.trigger_jobs 文档(10→12 字段,归档独立文档) - docs/database/README.md 索引更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
502
docs/specs/board-finance-dws-area-refactor/design.md
Normal file
502
docs/specs/board-finance-dws-area-refactor/design.md
Normal file
@@ -0,0 +1,502 @@
|
||||
# Design Document: 财务看板 DWS 区域维度重构
|
||||
|
||||
## Overview
|
||||
|
||||
本次重构解决财务看板在 `area≠all` 时优惠数据从全局 DWS 表取数导致区域级优惠占比严重失真的核心 bug(如 B区优惠占比 417.9%)。
|
||||
|
||||
采用两层架构:
|
||||
1. **原子层** `dws_finance_area_daily`:按 `(site_id, stat_date, area_code)` 日粒度存储 9 个区域的收入、优惠、现金流等预计算数据
|
||||
2. **缓存层** `dws_finance_board_cache`:缓存已完成周期的聚合结果,避免重复 SUM 计算
|
||||
|
||||
后端查询改为:已完成周期先查缓存 → 未命中从日粒度表 SUM → 当期周期直接从日粒度表 SUM。API 签名和返回结构完全不变,前端零改动。
|
||||
|
||||
核心设计决策:
|
||||
- 优惠按结算单桌台区域直接聚合,不做分摊(每张结算单对应一张桌台)
|
||||
- 区域映射抽成共享包,ETL 和后端共用同一份配置
|
||||
- `discount_gift_card` 使用赠送卡消费金额口径(与现有 ETL 一致)
|
||||
- 现金流/充值/卡消费仅 `area_code='all'` 时有值,区域级无法拆分
|
||||
|
||||
## Architecture
|
||||
|
||||
### 系统架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "数据源"
|
||||
DWD[dwd_settlement_head<br/>结算单明细]
|
||||
DIM[dim_table<br/>桌台维度表 SCD2]
|
||||
DWS_OLD[dws_finance_daily_summary<br/>现有全局日汇总]
|
||||
end
|
||||
|
||||
subgraph "共享包 packages/shared/"
|
||||
AM[area_mapping.py<br/>AREA_LABEL_MAP + resolve_area_code]
|
||||
end
|
||||
|
||||
subgraph "ETL 层 apps/etl/"
|
||||
ETL1[DWS_FINANCE_AREA_DAILY<br/>每小时 · delete-before-insert]
|
||||
ETL2[DWS_FINANCE_BOARD_CACHE<br/>每天 · 指纹对比]
|
||||
end
|
||||
|
||||
subgraph "DWS 新表"
|
||||
T1[dws_finance_area_daily<br/>原子层 · 9行/天/站点]
|
||||
T2[dws_finance_board_cache<br/>缓存层 · 已完成周期]
|
||||
end
|
||||
|
||||
subgraph "后端 apps/backend/"
|
||||
SVC[board_service.py<br/>缓存优先查询逻辑]
|
||||
FDW[fdw_queries.py<br/>get_finance_overview_area<br/>get_finance_revenue_area]
|
||||
end
|
||||
|
||||
subgraph "前端(不改动)"
|
||||
MP[小程序财务看板页]
|
||||
end
|
||||
|
||||
DWD --> ETL1
|
||||
DIM --> ETL1
|
||||
DWS_OLD --> ETL1
|
||||
AM --> ETL1
|
||||
AM --> FDW
|
||||
ETL1 --> T1
|
||||
T1 --> ETL2
|
||||
ETL2 --> T2
|
||||
T1 --> FDW
|
||||
T2 --> FDW
|
||||
FDW --> SVC
|
||||
SVC --> MP
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as 小程序
|
||||
participant API as FastAPI
|
||||
participant SVC as board_service
|
||||
participant Cache as dws_finance_board_cache
|
||||
participant Daily as dws_finance_area_daily
|
||||
|
||||
Client->>API: GET /api/xcx/board/finance?time=X&area=Y&compare=Z
|
||||
API->>SVC: get_finance_board(time, area, compare, site_id)
|
||||
|
||||
alt 已完成周期 (lastMonth/lastWeek/...)
|
||||
SVC->>Cache: 查询缓存
|
||||
alt 缓存命中
|
||||
Cache-->>SVC: 返回缓存数据
|
||||
else 缓存未命中
|
||||
SVC->>Daily: SUM(area_code=Y, date_range)
|
||||
Daily-->>SVC: 聚合结果
|
||||
SVC->>Cache: 写入缓存
|
||||
end
|
||||
else 当期周期 (month/week/quarter)
|
||||
SVC->>Daily: SUM(area_code=Y, date_range)
|
||||
Daily-->>SVC: 聚合结果
|
||||
end
|
||||
|
||||
alt compare=1
|
||||
SVC->>SVC: 对上期执行同样逻辑
|
||||
SVC->>SVC: calc_compare(当期, 上期)
|
||||
end
|
||||
|
||||
SVC-->>API: FinanceBoardResponse
|
||||
API-->>Client: JSON (camelCase)
|
||||
```
|
||||
|
||||
### ETL 任务依赖
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[DWD_LOAD_FROM_ODS] --> B[DWS_FINANCE_AREA_DAILY<br/>每小时]
|
||||
B --> C[DWS_FINANCE_BOARD_CACHE<br/>每天一次]
|
||||
A --> D[DWS_FINANCE_DAILY<br/>现有任务·不改动]
|
||||
```
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### 1. 共享区域映射 — `packages/shared/src/neozqyy_shared/area_mapping.py`
|
||||
|
||||
```python
|
||||
# 区域编码 → 物理区域名称列表
|
||||
AREA_LABEL_MAP: dict[str, list[str]] = {
|
||||
"hallA": ["A区"],
|
||||
"hallB": ["B区"],
|
||||
"hallC": ["C区", "TV台", "美洲豹赛台"],
|
||||
"vip": ["VIP包厢"],
|
||||
"snooker": ["斯诺克区"],
|
||||
"mahjong": ["麻将房", "M7", "M8", "666", "发财"],
|
||||
"ktv": ["K包", "k包活动区", "幸会158"],
|
||||
}
|
||||
|
||||
# 所有具体区域编码(不含 all/hall)
|
||||
SPECIFIC_AREA_CODES: list[str] # ["hallA", "hallB", ..., "ktv"]
|
||||
|
||||
# 全部 9 个区域编码
|
||||
ALL_AREA_CODES: list[str] # ["all", "hall", "hallA", ..., "ktv"]
|
||||
|
||||
# 反向映射:物理区域名称 → 区域编码
|
||||
_REVERSE_MAP: dict[str, str] # {"A区": "hallA", "B区": "hallB", ...}
|
||||
|
||||
def resolve_area_code(area_name: str | None) -> str | None:
|
||||
"""输入 site_table_area_name,返回对应的 area_code。未匹配返回 None。"""
|
||||
|
||||
def get_area_labels(area_code: str) -> list[str] | None:
|
||||
"""输入 area_code,返回对应的物理区域名称列表。all/hall 返回 None。"""
|
||||
```
|
||||
|
||||
设计决策:
|
||||
- `hall` = 所有具体区域之和(不含 all),语义上等同于 all(历史兼容)
|
||||
- `all` = 所有区域之和
|
||||
- 未匹配的 `area_name` 返回 `None`,由 ETL 决定是否记录警告
|
||||
|
||||
### 2. ETL 任务 — `DWS_FINANCE_AREA_DAILY`
|
||||
|
||||
位置:`apps/etl/connectors/feiqiu/tasks/dws/finance_area_daily.py`
|
||||
|
||||
继承 `FinanceBaseTask`(复用结算单提取方法),覆盖 `extract` / `transform` / `load`:
|
||||
|
||||
- **extract**:从 `dwd_settlement_head` + `dim_table` 提取当天结算单(按营业日切点),同时从 `dws_finance_daily_summary` 提取全局现金流/充值/卡消费字段
|
||||
- **transform**:使用 `resolve_area_code` 将每张结算单映射到区域,按区域聚合收入和优惠字段,构建 9 行(7 个具体区域 + hall + all)
|
||||
- **load**:delete-before-insert(按 `site_id + stat_date` 删除后插入 9 行)
|
||||
|
||||
关键接口:
|
||||
```python
|
||||
class FinanceAreaDailyTask(FinanceBaseTask):
|
||||
def get_task_code(self) -> str: return "DWS_FINANCE_AREA_DAILY"
|
||||
def get_target_table(self) -> str: return "dws.dws_finance_area_daily"
|
||||
def get_primary_keys(self) -> list[str]: return ["site_id", "stat_date", "area_code"]
|
||||
def extract(self, context: TaskContext) -> dict: ...
|
||||
def transform(self, extracted: dict, context: TaskContext) -> list[dict]: ...
|
||||
```
|
||||
|
||||
### 3. ETL 任务 — `DWS_FINANCE_BOARD_CACHE`
|
||||
|
||||
位置:`apps/etl/connectors/feiqiu/tasks/dws/finance_board_cache.py`
|
||||
|
||||
继承 `BaseDwsTask`:
|
||||
|
||||
- **extract**:遍历 5 个已完成周期 × 9 个区域 = 45 组合,对每个组合从 `dws_finance_area_daily` 读取日粒度行
|
||||
- **transform**:计算数据指纹(MD5),与缓存表对比,标记需要重算的组合
|
||||
- **load**:对需要重算的组合,从日粒度表 SUM 后 upsert 到缓存表
|
||||
|
||||
指纹计算:
|
||||
```python
|
||||
def compute_fingerprint(rows: list[dict]) -> str:
|
||||
"""对 (stat_date, gross_amount, discount_total) 排序后 MD5"""
|
||||
sorted_rows = sorted(rows, key=lambda r: str(r['stat_date']))
|
||||
payload = json.dumps([(str(r['stat_date']), str(r['gross_amount']), str(r['discount_total'])) for r in sorted_rows])
|
||||
return hashlib.md5(payload.encode()).hexdigest()
|
||||
```
|
||||
|
||||
### 4. 后端查询改造 — `fdw_queries.py`
|
||||
|
||||
新增/改造函数:
|
||||
|
||||
```python
|
||||
def get_finance_overview_area(
|
||||
conn, site_id: int, start_date: str, end_date: str, area_code: str = "all"
|
||||
) -> dict:
|
||||
"""从 v_dws_finance_area_daily 按 area_code 聚合 overview 8 项指标"""
|
||||
|
||||
def get_finance_revenue_area(
|
||||
conn, site_id: int, start_date: str, end_date: str, area_code: str = "all"
|
||||
) -> dict:
|
||||
"""从 v_dws_finance_area_daily 按 area_code 聚合 revenue 板块数据"""
|
||||
|
||||
def get_finance_board_cache(
|
||||
conn, site_id: int, time_range: str, area_code: str
|
||||
) -> dict | None:
|
||||
"""查询 v_dws_finance_board_cache 缓存"""
|
||||
|
||||
def set_finance_board_cache(
|
||||
conn, site_id: int, time_range: str, area_code: str, data: dict
|
||||
) -> None:
|
||||
"""写入/更新缓存"""
|
||||
```
|
||||
|
||||
### 5. 后端服务改造 — `board_service.py`
|
||||
|
||||
`get_finance_board` 函数改造:
|
||||
- 新增缓存查询逻辑:已完成周期先查缓存
|
||||
- `_build_overview` 改为调用 `get_finance_overview_area`(传入 area_code)
|
||||
- `_build_revenue` 改为调用 `get_finance_revenue_area`(传入 area_code)
|
||||
- `_build_cashflow` 不变(始终用全局数据)
|
||||
- `area≠all` 时 overview 覆盖逻辑保留
|
||||
|
||||
## Data Models
|
||||
|
||||
### 1. `dws_finance_area_daily` — 原子层
|
||||
|
||||
```sql
|
||||
CREATE TABLE dws.dws_finance_area_daily (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
site_id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
stat_date DATE NOT NULL,
|
||||
area_code VARCHAR(20) NOT NULL,
|
||||
-- 收入结构(4 项 + gross_amount)
|
||||
table_fee_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
goods_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
assistant_pd_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
assistant_cx_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
gross_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
-- 优惠拆分(6 项 + discount_total)
|
||||
discount_groupbuy NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
discount_vip NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
discount_manual NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
discount_gift_card NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
discount_rounding NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
discount_other NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
discount_total NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
-- 确认收入
|
||||
confirmed_income NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
-- 现金流(仅 area_code='all')
|
||||
cash_pay_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
cash_paper_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
scan_pay_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
groupbuy_pay_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
recharge_cash_inflow NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
cash_inflow_total NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
cash_outflow_total NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
cash_balance_change NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
-- 卡消费(仅 area_code='all')
|
||||
card_consume_total NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
recharge_card_consume NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
gift_card_consume NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
-- 充值(仅 area_code='all')
|
||||
recharge_cash NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
first_recharge_cash NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
renewal_cash NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
-- 订单统计
|
||||
order_count INTEGER NOT NULL DEFAULT 0,
|
||||
-- 元数据
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (site_id, stat_date, area_code)
|
||||
);
|
||||
```
|
||||
|
||||
约束与恒等式:
|
||||
- `gross_amount = table_fee_amount + goods_amount + assistant_pd_amount + assistant_cx_amount`
|
||||
- `discount_total = discount_groupbuy + discount_vip + discount_manual + discount_gift_card + discount_rounding + discount_other`
|
||||
- `confirmed_income = gross_amount - discount_total`
|
||||
- `area_code ∈ {all, hall, hallA, hallB, hallC, vip, snooker, mahjong, ktv}`
|
||||
- `area_code ≠ 'all'` 时现金流/卡消费/充值字段 = 0
|
||||
|
||||
### 2. `dws_finance_board_cache` — 缓存层
|
||||
|
||||
```sql
|
||||
CREATE TABLE dws.dws_finance_board_cache (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
site_id BIGINT NOT NULL,
|
||||
time_range VARCHAR(20) NOT NULL,
|
||||
area_code VARCHAR(20) NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
prev_start_date DATE,
|
||||
prev_end_date DATE,
|
||||
-- overview 8 项
|
||||
occurrence NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
discount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
discount_rate NUMERIC(8,4) NOT NULL DEFAULT 0,
|
||||
confirmed_revenue NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
cash_in NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
cash_out NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
cash_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
balance_rate NUMERIC(8,4) NOT NULL DEFAULT 0,
|
||||
-- 指纹
|
||||
data_fingerprint VARCHAR(64),
|
||||
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
-- 元数据
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (site_id, time_range, area_code)
|
||||
);
|
||||
```
|
||||
|
||||
缓存策略:
|
||||
- `time_range ∈ {lastMonth, lastWeek, lastQuarter, quarter3, half6}` → 缓存
|
||||
- `time_range ∈ {month, week, quarter}` → 不缓存
|
||||
- 失效条件:`data_fingerprint` 变化(补录导致)
|
||||
|
||||
### 3. 区域映射数据模型
|
||||
|
||||
```python
|
||||
# area_code 枚举值
|
||||
AREA_CODES = Literal[
|
||||
"all", "hall", "hallA", "hallB", "hallC",
|
||||
"vip", "snooker", "mahjong", "ktv"
|
||||
]
|
||||
|
||||
# AREA_LABEL_MAP 结构
|
||||
AREA_LABEL_MAP: dict[str, list[str]] = {
|
||||
"hallA": ["A区"],
|
||||
"hallB": ["B区"],
|
||||
"hallC": ["C区", "TV台", "美洲豹赛台"],
|
||||
"vip": ["VIP包厢"],
|
||||
"snooker": ["斯诺克区"],
|
||||
"mahjong": ["麻将房", "M7", "M8", "666", "发财"],
|
||||
"ktv": ["K包", "k包活动区", "幸会158"],
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## 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: 区域映射 round-trip
|
||||
|
||||
*For any* `area_name` 存在于 `AREA_LABEL_MAP` 的某个值列表中,`resolve_area_code(area_name)` 应返回对应的 `area_code`,且 `area_name in get_area_labels(resolve_area_code(area_name))` 为 True。
|
||||
|
||||
**Validates: Requirements 1.1, 1.5**
|
||||
|
||||
### Property 2: 未知区域名称返回 None
|
||||
|
||||
*For any* 不在 `AREA_LABEL_MAP` 任何值列表中的字符串 `area_name`,`resolve_area_code(area_name)` 应返回 `None`。
|
||||
|
||||
**Validates: Requirements 1.4**
|
||||
|
||||
### Property 3: 日粒度行数学恒等式
|
||||
|
||||
*For any* `dws_finance_area_daily` 行,以下三个恒等式必须同时成立:
|
||||
1. `gross_amount = table_fee_amount + goods_amount + assistant_pd_amount + assistant_cx_amount`
|
||||
2. `discount_total = discount_groupbuy + discount_vip + discount_manual + discount_gift_card + discount_rounding + discount_other`
|
||||
3. `confirmed_income = gross_amount - discount_total`
|
||||
|
||||
**Validates: Requirements 2.1, 2.2, 2.3, 8.3**
|
||||
|
||||
### Property 4: 非 all 区域现金流/卡消费/充值为零
|
||||
|
||||
*For any* `dws_finance_area_daily` 行,当 `area_code ≠ 'all'` 时,所有现金流字段(cash_pay_amount, cash_paper_amount, scan_pay_amount, groupbuy_pay_amount, recharge_cash_inflow, cash_inflow_total, cash_outflow_total, cash_balance_change)、卡消费字段(card_consume_total, recharge_card_consume, gift_card_consume)和充值字段(recharge_cash, first_recharge_cash, renewal_cash)均应为 0。
|
||||
|
||||
**Validates: Requirements 2.5**
|
||||
|
||||
### Property 5: ETL 输出完整性与聚合正确性
|
||||
|
||||
*For any* 一组结算单输入数据,ETL transform 应输出恰好 9 行(area_code 覆盖 all/hall/hallA/hallB/hallC/vip/snooker/mahjong/ktv),且 `all` 行的收入和优惠字段 = hallA~ktv 各行对应字段之和,`hall` 行的收入和优惠字段 = hallA~ktv 各行对应字段之和。
|
||||
|
||||
**Validates: Requirements 2.7, 2.8, 8.4**
|
||||
|
||||
### Property 6: ETL 幂等性(delete-before-insert)
|
||||
|
||||
*For any* 一组结算单输入数据,对同一 `(site_id, stat_date)` 运行两次 ETL transform,两次输出应完全相同。
|
||||
|
||||
**Validates: Requirements 3.4**
|
||||
|
||||
### Property 7: settle_type 过滤
|
||||
|
||||
*For any* 一组包含不同 `settle_type` 值的结算单,ETL 仅处理 `settle_type IN (1, 3)` 的记录,其他 settle_type 的结算单不应影响输出金额。
|
||||
|
||||
**Validates: Requirements 3.6**
|
||||
|
||||
### Property 8: 数据指纹确定性与缓存失效
|
||||
|
||||
*For any* 一组日粒度行,`compute_fingerprint` 是确定性的(相同输入产生相同输出)。且 *for any* 对源数据的修改(改变任意行的 gross_amount 或 discount_total),新指纹应与原指纹不同。
|
||||
|
||||
**Validates: Requirements 5.2, 5.3, 5.4**
|
||||
|
||||
### Property 9: 当期周期不写入缓存
|
||||
|
||||
*For any* `time_range ∈ {month, week, quarter}`,ETL 缓存任务不应为该 time_range 写入缓存记录。
|
||||
|
||||
**Validates: Requirements 5.7**
|
||||
|
||||
### Property 10: 查询路由正确性
|
||||
|
||||
*For any* 查询请求,当 `time_range` 为已完成周期且缓存存在时,应直接返回缓存数据;当缓存不存在时,应从日粒度表 SUM 计算并写入缓存;当 `time_range` 为当期周期时,应直接从日粒度表 SUM 计算,不查缓存。
|
||||
|
||||
**Validates: Requirements 6.1, 6.2, 6.3, 9.4**
|
||||
|
||||
### Property 11: 区域过滤行为
|
||||
|
||||
*For any* `area_code ≠ 'all'` 的查询,`recharge` 板块应返回 `null`,`cashflow`/`expense`/`coach_analysis` 板块的数据应与 `area_code='all'` 时一致。
|
||||
|
||||
**Validates: Requirements 6.7, 6.8**
|
||||
|
||||
### Property 12: revenue 固定项数
|
||||
|
||||
*For any* 查询返回的 revenue 板块,`discount_items` 应恰好包含 5 项(团购/会员折扣/手动调整/赠送卡/其他),`channel_items` 应恰好包含 3 项(储值卡结算冲销/现金线上支付/团购核销)。
|
||||
|
||||
**Validates: Requirements 7.3, 7.4**
|
||||
|
||||
### Property 13: area≠all 时 overview 覆盖逻辑
|
||||
|
||||
*For any* `area_code ≠ 'all'` 的查询,`overview.occurrence` 应等于 `revenue.total_occurrence`,`overview.discount` 应等于 `revenue.discount_total`,`overview.confirmed_revenue` 应等于 `revenue.confirmed_total`。
|
||||
|
||||
**Validates: Requirements 7.6**
|
||||
|
||||
### Property 14: area=all 回归一致性
|
||||
|
||||
*For any* 日期范围和 `area_code='all'` 的查询,新逻辑(从 `dws_finance_area_daily` 查询)的 overview 板块 8 项指标应与旧逻辑(从 `dws_finance_daily_summary` 查询)的结果完全一致。
|
||||
|
||||
**Validates: Requirements 9.1**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### ETL 层
|
||||
|
||||
| 场景 | 处理策略 |
|
||||
|------|---------|
|
||||
| `resolve_area_code` 返回 None(未知区域) | 记录 WARNING 日志,该结算单不计入任何具体区域行,但仍计入 all 行 |
|
||||
| `dws_finance_daily_summary` 无当天数据 | all 行的现金流/充值/卡消费字段填 0,记录 WARNING |
|
||||
| `dim_table` 中 `table_id` 无匹配(`scd2_is_current=1`) | 该结算单的 area_code 视为 None,同上处理 |
|
||||
| delete-before-insert 事务失败 | 整个事务回滚,任务标记失败,下次调度重试 |
|
||||
| 指纹计算时日粒度表无数据 | 指纹为空字符串的 MD5,缓存标记为"无数据" |
|
||||
|
||||
### 后端查询层
|
||||
|
||||
| 场景 | 处理策略 |
|
||||
|------|---------|
|
||||
| `dws_finance_area_daily` 无数据(新站点/新日期) | 返回全零的 overview/revenue,与现有降级逻辑一致 |
|
||||
| 缓存写入失败 | 不影响查询结果返回,记录 ERROR 日志,下次请求重试写入 |
|
||||
| 缓存表连接失败 | 降级为直接从日粒度表 SUM,不中断请求 |
|
||||
| area_code 参数非法 | 由 FastAPI 的 AreaFilterEnum 校验拦截,返回 422 |
|
||||
|
||||
### 数据一致性保护
|
||||
|
||||
- ETL delete-before-insert 在单个事务内执行,保证原子性
|
||||
- 缓存写入使用 `ON CONFLICT ... DO UPDATE`,保证幂等性
|
||||
- 后端查询使用 `SET LOCAL app.current_site_id` 保证 RLS 隔离
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 属性测试(Property-Based Testing)
|
||||
|
||||
使用 **hypothesis** 库(Python),每个属性测试最少 100 次迭代。
|
||||
|
||||
| Property | 测试文件 | 生成器 |
|
||||
|----------|---------|--------|
|
||||
| Property 1-2 | `tests/test_area_mapping_props.py` | `st.text()` 生成随机 area_name |
|
||||
| Property 3-7 | `tests/test_finance_area_daily_props.py` | 生成随机结算单列表(金额用 `st.decimals`,area_name 从已知+未知混合) |
|
||||
| Property 8-9 | `tests/test_finance_board_cache_props.py` | 生成随机日粒度行列表 |
|
||||
| Property 10-14 | `tests/test_board_service_props.py` | 生成随机查询参数 + mock 数据库返回 |
|
||||
|
||||
每个测试函数必须包含注释标签:
|
||||
```python
|
||||
# Feature: board-finance-dws-area-refactor, Property 1: 区域映射 round-trip
|
||||
```
|
||||
|
||||
### 单元测试
|
||||
|
||||
| 测试范围 | 测试文件 | 关注点 |
|
||||
|---------|---------|--------|
|
||||
| area_mapping | `tests/test_area_mapping_unit.py` | 边界:空字符串、None、大小写、特殊字符 |
|
||||
| ETL transform | `apps/etl/connectors/feiqiu/tests/unit/test_finance_area_daily.py` | discount_gift_card 口径验证、营业日切点边界 |
|
||||
| ETL cache | `apps/etl/connectors/feiqiu/tests/unit/test_finance_board_cache.py` | 指纹变化检测、空数据处理 |
|
||||
| 后端查询 | `apps/backend/tests/unit/test_fdw_queries_area.py` | SQL 正确性、area_code 过滤、缓存命中/未命中 |
|
||||
| 后端服务 | `apps/backend/tests/unit/test_board_service_area.py` | 覆盖逻辑、环比计算、降级行为 |
|
||||
| 回归验证 | `scripts/ops/validate_board_finance.py` | 144 组合全量对比 |
|
||||
|
||||
### 测试配置
|
||||
|
||||
```python
|
||||
# conftest.py / hypothesis settings
|
||||
from hypothesis import settings
|
||||
settings.register_profile("ci", max_examples=100)
|
||||
settings.register_profile("dev", max_examples=30)
|
||||
```
|
||||
|
||||
### 集成验证
|
||||
|
||||
- 144 组合全量验证脚本:`scripts/ops/validate_board_finance.py`(8 time_range × 9 area_code × 2 compare)
|
||||
- area=all 回归对比:新旧逻辑输出 diff
|
||||
- 缓存命中率验证:已完成周期第二次请求不触发 SUM
|
||||
141
docs/specs/board-finance-dws-area-refactor/requirements.md
Normal file
141
docs/specs/board-finance-dws-area-refactor/requirements.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
财务看板 DWS 区域维度重构。当前财务看板在 area≠all 时,优惠数据仍从全局 DWS 表取数,导致区域级优惠占比严重失真(如 B区优惠占比 417.9%)。本次重构新建区域日粒度原子层表 `dws_finance_area_daily` 和已完成周期缓存层表 `dws_finance_board_cache`,将优惠按结算单桌台区域直接聚合,后端查询改为先查缓存、未命中从日粒度表 SUM,同时保持 API 签名和返回结构完全不变(前端零改动)。
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Finance_Board**:财务看板,后端 API `/api/xcx/board/finance` 返回的 6 个板块数据(overview/recharge/revenue/cashflow/expense/coach_analysis)
|
||||
- **Area_Daily_Table**:区域日粒度原子层表 `dws_finance_area_daily`,按 `(site_id, stat_date, area_code)` 存储每日每区域的收入、优惠、现金流等预计算数据
|
||||
- **Board_Cache_Table**:看板缓存层表 `dws_finance_board_cache`,缓存已完成周期的聚合结果,按 `(site_id, time_range, area_code)` 唯一
|
||||
- **Area_Code**:区域编码枚举,取值为 all/hall/hallA/hallB/hallC/vip/snooker/mahjong/ktv 共 9 个值
|
||||
- **Area_Mapping**:区域映射配置,将 `dim_table.site_table_area_name`(如 "A区"、"VIP包厢")映射到 Area_Code 的共享配置
|
||||
- **ETL_Area_Daily_Task**:DWS_FINANCE_AREA_DAILY ETL 任务,从 DWD 层按区域聚合计算日粒度数据
|
||||
- **ETL_Cache_Task**:DWS_FINANCE_BOARD_CACHE ETL 任务,基于数据指纹机制维护已完成周期的缓存
|
||||
- **Data_Fingerprint**:数据指纹,对源数据关键字段计算 MD5 hash,用于检测已完成周期的数据是否因补录而变化
|
||||
- **Business_Day_Cutoff**:营业日切点,`BUSINESS_DAY_START_HOUR=8`,当日 08:00 前的结算单归属前一营业日
|
||||
- **Settlement_Head**:结算单主表 `dwd_settlement_head`,每张结算单对应一张桌台,通过 `table_id` 关联 `dim_table` 获取区域归属
|
||||
- **Discount_Identity**:优惠拆分恒等式,`discount_total = discount_groupbuy + discount_vip + discount_manual + discount_gift_card + discount_rounding + discount_other`
|
||||
- **Completed_Period**:已完成周期,指 lastMonth/lastWeek/lastQuarter/quarter3/half6 等时间范围,数据不再变化(除补录外)
|
||||
- **Current_Period**:当期周期,指 month/week/quarter 等时间范围,数据每天在变,不缓存
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: 共享区域映射配置
|
||||
|
||||
**User Story:** As a 开发者, I want 区域映射配置集中在 `packages/shared/` 中维护, so that ETL 和后端共用同一份映射,避免不一致
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Area_Mapping SHALL 定义 AREA_LABEL_MAP 字典,将每个 Area_Code(hallA/hallB/hallC/vip/snooker/mahjong/ktv)映射到对应的 `site_table_area_name` 列表
|
||||
2. THE Area_Mapping SHALL 存放在 `packages/shared/src/neozqyy_shared/area_mapping.py` 中,ETL 和后端通过 import 引用
|
||||
3. THE Area_Mapping SHALL 定义 hall = 所有具体区域之和(不含 all),all = 所有区域之和
|
||||
4. WHEN 桌台的 `site_table_area_name` 不匹配任何 AREA_LABEL_MAP 条目, THEN THE Area_Mapping SHALL 将该桌台归入一个可配置的默认区域(或排除),并在 ETL 日志中记录警告
|
||||
5. THE Area_Mapping SHALL 提供 `resolve_area_code(area_name: str) -> str` 函数,输入 `site_table_area_name` 返回对应的 Area_Code
|
||||
|
||||
### Requirement 2: 区域日粒度原子层表
|
||||
|
||||
**User Story:** As a 数据工程师, I want 按 `(site_id, stat_date, area_code)` 粒度预计算财务数据, so that 后端查询可以按区域过滤而不依赖全局 DWS 表
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Area_Daily_Table SHALL 包含收入结构 4 项(table_fee_amount/goods_amount/assistant_pd_amount/assistant_cx_amount)和 gross_amount,其中 gross_amount = 四项之和
|
||||
2. THE Area_Daily_Table SHALL 包含优惠拆分 6 项(discount_groupbuy/discount_vip/discount_manual/discount_gift_card/discount_rounding/discount_other)和 discount_total,满足 Discount_Identity 恒等式
|
||||
3. THE Area_Daily_Table SHALL 包含 confirmed_income 字段,其值 = gross_amount - discount_total
|
||||
4. WHILE area_code = 'all', THE Area_Daily_Table SHALL 包含现金流字段(cash_pay_amount/cash_paper_amount/scan_pay_amount/groupbuy_pay_amount/recharge_cash_inflow/cash_inflow_total/cash_outflow_total/cash_balance_change)的有效值
|
||||
5. WHILE area_code ≠ 'all', THE Area_Daily_Table SHALL 将现金流、卡消费、充值字段设为 0
|
||||
6. THE Area_Daily_Table SHALL 以 `(site_id, stat_date, area_code)` 为唯一约束
|
||||
7. WHEN ETL 写入某个 `(site_id, stat_date)` 的数据, THE Area_Daily_Table SHALL 包含 9 行(all + hall + hallA + hallB + hallC + vip + snooker + mahjong + ktv)
|
||||
8. THE Area_Daily_Table SHALL 中 all 行的收入和优惠字段 = 各具体区域行(hallA~ktv)对应字段之和
|
||||
|
||||
### Requirement 3: 区域日粒度 ETL 任务
|
||||
|
||||
**User Story:** As a 数据工程师, I want ETL 任务自动从 DWD 层按区域聚合计算日粒度数据, so that Area_Daily_Table 保持最新
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE ETL_Area_Daily_Task SHALL 从 Settlement_Head 和 `dim_table`(`scd2_is_current=1`)按区域聚合收入和优惠字段
|
||||
2. THE ETL_Area_Daily_Task SHALL 使用 Business_Day_Cutoff(BUSINESS_DAY_START_HOUR=8)计算 stat_date
|
||||
3. THE ETL_Area_Daily_Task SHALL 使用 Area_Mapping 的 `resolve_area_code` 将桌台映射到 Area_Code
|
||||
4. THE ETL_Area_Daily_Task SHALL 采用 delete-before-insert 策略:先删除目标 `(site_id, stat_date)` 的所有行,再插入 9 行
|
||||
5. THE ETL_Area_Daily_Task SHALL 从现有 `dws_finance_daily_summary` 复用全局现金流/充值/卡消费字段填充 all 行
|
||||
6. THE ETL_Area_Daily_Task SHALL 仅处理 `settle_type IN (1, 3)` 的结算单
|
||||
7. THE ETL_Area_Daily_Task SHALL 按每小时调度频率运行,与现有 DWS_FINANCE_DAILY 同频
|
||||
8. THE ETL_Area_Daily_Task SHALL 依赖 DWD_LOAD_FROM_ODS 任务完成后执行
|
||||
9. THE ETL_Area_Daily_Task SHALL 确保 `discount_gift_card` 使用赠送卡消费金额口径(与现有 ETL 一致),而非结算单的 `gift_card_amount`
|
||||
|
||||
### Requirement 4: 看板缓存层表
|
||||
|
||||
**User Story:** As a 后端开发者, I want 已完成周期的聚合结果被缓存, so that 重复查询不需要每次从日粒度表 SUM
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Board_Cache_Table SHALL 按 `(site_id, time_range, area_code)` 唯一存储缓存数据
|
||||
2. THE Board_Cache_Table SHALL 包含 overview 板块的 8 项核心指标(occurrence/discount/discount_rate/confirmed_revenue/cash_in/cash_out/cash_balance/balance_rate)
|
||||
3. THE Board_Cache_Table SHALL 包含 start_date/end_date 记录当期日期范围,prev_start_date/prev_end_date 记录上期日期范围(环比用)
|
||||
4. THE Board_Cache_Table SHALL 包含 Data_Fingerprint 字段和 computed_at 时间戳
|
||||
|
||||
### Requirement 5: 缓存层 ETL 任务与指纹机制
|
||||
|
||||
**User Story:** As a 数据工程师, I want 缓存层通过数据指纹自动检测数据变化并重算, so that 补录数据后缓存自动失效和更新
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE ETL_Cache_Task SHALL 遍历所有 Completed_Period(lastMonth/lastWeek/lastQuarter/quarter3/half6)× 9 个 Area_Code 组合
|
||||
2. THE ETL_Cache_Task SHALL 对每个组合计算源数据的 Data_Fingerprint(基于日粒度行的 stat_date/gross_amount/discount_total 的 MD5 hash)
|
||||
3. WHEN Data_Fingerprint 与 Board_Cache_Table 中已有指纹不一致, THEN THE ETL_Cache_Task SHALL 从 Area_Daily_Table SUM 重算该组合的缓存数据并更新
|
||||
4. WHEN Data_Fingerprint 与 Board_Cache_Table 中已有指纹一致, THE ETL_Cache_Task SHALL 跳过该组合的重算
|
||||
5. THE ETL_Cache_Task SHALL 依赖 ETL_Area_Daily_Task 完成后执行
|
||||
6. THE ETL_Cache_Task SHALL 按每天一次调度(营业日切点后)
|
||||
7. WHILE time_range 为 Current_Period(month/week/quarter), THE ETL_Cache_Task SHALL 不写入缓存(当期数据每天在变)
|
||||
|
||||
### Requirement 6: 后端查询改造
|
||||
|
||||
**User Story:** As a 后端开发者, I want 后端查询逻辑改为先查缓存再查日粒度表, so that overview 和 revenue 板块支持按区域正确过滤
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN time_range 为 Completed_Period, THE Finance_Board SHALL 先查 Board_Cache_Table 获取缓存数据
|
||||
2. WHEN Board_Cache_Table 未命中(无记录), THE Finance_Board SHALL 从 Area_Daily_Table SUM 计算结果,写入缓存后返回
|
||||
3. WHEN time_range 为 Current_Period, THE Finance_Board SHALL 直接从 Area_Daily_Table SUM 计算结果
|
||||
4. WHEN compare=1, THE Finance_Board SHALL 对上期也执行同样的缓存/日粒度查询逻辑,然后计算环比
|
||||
5. THE Finance_Board SHALL 将 overview 板块的数据来源从 `dws_finance_daily_summary` 改为 Area_Daily_Table(按 area_code 过滤)
|
||||
6. THE Finance_Board SHALL 将 revenue 板块的数据来源从 Settlement_Head 实时查询改为 Area_Daily_Table(收入+优惠+渠道全部预计算)
|
||||
7. WHILE area_code ≠ 'all', THE Finance_Board SHALL 对 cashflow/expense/coach_analysis 板块仍使用全局数据
|
||||
8. WHILE area_code ≠ 'all', THE Finance_Board SHALL 对 recharge 板块返回 null
|
||||
|
||||
### Requirement 7: 接口契约不变(前端零改动)
|
||||
|
||||
**User Story:** As a 前端开发者, I want API 签名和返回数据结构完全不变, so that 前端无需任何改动
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Finance_Board SHALL 保持 API 签名不变:`GET /api/xcx/board/finance?time={FinanceTimeEnum}&area={AreaFilterEnum}&compare={0|1}`
|
||||
2. THE Finance_Board SHALL 保持所有 Pydantic Schema 类不变(FinanceBoardResponse/OverviewPanel/RechargePanel/RevenuePanel/CashflowPanel/ExpensePanel/CoachAnalysisPanel),不新增、不删除、不改名任何字段
|
||||
3. THE Finance_Board SHALL 保持 revenue.discount_items 固定 5 项(团购/会员折扣/手动调整/赠送卡/其他)
|
||||
4. THE Finance_Board SHALL 保持 revenue.channel_items 固定 3 项(储值卡结算冲销/现金线上支付/团购核销)
|
||||
5. WHEN compare=1, THE Finance_Board SHALL 返回环比字段(格式为 "X.X%"/"持平"/"新增");WHEN compare=0, THE Finance_Board SHALL 返回环比字段为 null
|
||||
6. WHILE area_code ≠ 'all', THE Finance_Board SHALL 保持 overview.occurrence/discount/confirmedRevenue = revenue 板块的对应值(后端覆盖逻辑保留)
|
||||
|
||||
### Requirement 8: 优惠按区域正确归属
|
||||
|
||||
**User Story:** As a 门店管理员, I want 查看某区域的优惠数据时看到的是该区域实际发生的优惠, so that 优惠占比反映真实业务情况
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE ETL_Area_Daily_Task SHALL 将每张结算单的优惠通过 `table_id → dim_table.site_table_area_name → Area_Code` 映射归属到对应区域
|
||||
2. THE ETL_Area_Daily_Task SHALL 对优惠按区域直接聚合,不做任何分摊计算
|
||||
3. FOR ALL Area_Code 值, THE Area_Daily_Table SHALL 满足 Discount_Identity:discount_total = discount_groupbuy + discount_vip + discount_manual + discount_gift_card + discount_rounding + discount_other
|
||||
4. WHILE area_code = 'all', THE Area_Daily_Table SHALL 中 discount_total = 各具体区域(hallA~ktv)的 discount_total 之和
|
||||
|
||||
### Requirement 9: 回归测试与全量验证
|
||||
|
||||
**User Story:** As a 开发者, I want 重构后 area=all 的数据与现有逻辑完全一致, so that 确保重构不引入新的数据偏差
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN area_code = 'all', THE Finance_Board SHALL 返回与重构前完全一致的数据(所有 6 个板块)
|
||||
2. THE Finance_Board SHALL 通过 144 组合全量验证(8 个 time_range × 9 个 area_code × 2 个 compare 值)
|
||||
3. WHEN area_code ≠ 'all', THE Finance_Board SHALL 返回的 discountRate 不出现 400%+ 的异常值
|
||||
4. WHEN 已完成周期被第二次请求, THE Finance_Board SHALL 从 Board_Cache_Table 命中缓存,不触发 Area_Daily_Table 的 SUM 计算
|
||||
289
docs/specs/board-finance-dws-area-refactor/tasks.md
Normal file
289
docs/specs/board-finance-dws-area-refactor/tasks.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Implementation Plan: 财务看板 DWS 区域维度重构
|
||||
|
||||
## Overview
|
||||
|
||||
将财务看板的优惠数据从全局 DWS 取数改为按区域日粒度预计算,分 4 个阶段实施:基础设施层(共享映射 + DDL)→ ETL 层(两个新任务)→ 后端层(查询改造 + 缓存逻辑)→ 收尾(联调、DDL 合并、文档、审计)。每个阶段末尾设检查点,确保增量验证。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. 共享区域映射配置与属性测试
|
||||
- [x] 1.1 创建 `packages/shared/src/neozqyy_shared/area_mapping.py`
|
||||
- 定义 `AREA_LABEL_MAP` 字典(7 个具体区域 → 物理名称列表)
|
||||
- 定义 `SPECIFIC_AREA_CODES`、`ALL_AREA_CODES` 常量
|
||||
- 构建 `_REVERSE_MAP` 反向映射
|
||||
- 实现 `resolve_area_code(area_name)` 和 `get_area_labels(area_code)` 函数
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.5_
|
||||
|
||||
- [x] 1.2 编写属性测试:区域映射 round-trip
|
||||
- **Property 1: 区域映射 round-trip**
|
||||
- 生成器:从 `AREA_LABEL_MAP` 所有值列表中随机选取 `area_name`
|
||||
- 验证:`resolve_area_code(area_name)` 返回正确 `area_code`,且 `area_name in get_area_labels(result)`
|
||||
- **验证: 需求 1.1, 1.5**
|
||||
|
||||
- [x] 1.3 编写属性测试:未知区域名称返回 None
|
||||
- **Property 2: 未知区域名称返回 None**
|
||||
- 生成器:`st.text()` 生成随机字符串,过滤掉已知 area_name
|
||||
- 验证:`resolve_area_code(unknown_name)` 返回 `None`
|
||||
- **验证: 需求 1.4**
|
||||
|
||||
- [x] 1.4 编写单元测试:area_mapping 边界条件
|
||||
- 测试文件:`tests/test_area_mapping_unit.py`
|
||||
- 覆盖:空字符串、None、大小写敏感、特殊字符、hall/all 的 get_area_labels 返回 None
|
||||
- _Requirements: 1.4, 1.5_
|
||||
|
||||
- [x] 2. DDL:创建 dws_finance_area_daily 表与 RLS 视图
|
||||
- [x] 2.1 编写 DDL 迁移脚本
|
||||
- 创建 `dws.dws_finance_area_daily` 表(含收入 5 字段、优惠 7 字段、confirmed_income、现金流 8 字段、卡消费 3 字段、充值 3 字段、order_count)
|
||||
- 添加 UNIQUE 约束 `(site_id, stat_date, area_code)`
|
||||
- 创建 RLS 视图 `v_dws_finance_area_daily`(`WHERE site_id = current_setting('app.current_site_id')::bigint`)
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_
|
||||
|
||||
- [x] 3. 检查点 — 基础设施层验证
|
||||
- 确保 area_mapping 属性测试和单元测试通过:`cd C:\NeoZQYY && pytest tests/test_area_mapping_props.py tests/test_area_mapping_unit.py -v`
|
||||
- 确保 DDL 迁移脚本语法正确
|
||||
- ask the user if questions arise.
|
||||
|
||||
- [x] 4. ETL:DWS_FINANCE_AREA_DAILY 任务与属性测试
|
||||
- [x] 4.1 创建 `apps/etl/connectors/feiqiu/tasks/dws/finance_area_daily.py`
|
||||
- 继承 `FinanceBaseTask`,实现 `get_task_code`/`get_target_table`/`get_primary_keys`
|
||||
- `extract`:从 `dwd_settlement_head` + `dim_table`(`scd2_is_current=1`)提取当天结算单(`settle_type IN (1,3)`,按 `BUSINESS_DAY_START_HOUR` 切点),同时从 `dws_finance_daily_summary` 提取全局现金流/充值/卡消费
|
||||
- `transform`:使用 `resolve_area_code` 映射区域,按区域聚合收入和优惠字段,构建 9 行(hallA~ktv + hall + all),非 all 行现金流/卡消费/充值字段为 0
|
||||
- `load`:delete-before-insert(按 `site_id + stat_date` 删除后插入 9 行,单事务)
|
||||
- `discount_gift_card` 使用赠送卡消费金额口径(与现有 ETL 一致)
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 8.1, 8.2_
|
||||
|
||||
- [x] 4.2 编写属性测试:日粒度行数学恒等式
|
||||
- **Property 3: 日粒度行数学恒等式**
|
||||
- 生成器:随机生成结算单列表(金额用 `st.decimals`,area_name 从已知+未知混合)
|
||||
- 验证:transform 输出的每行满足 gross_amount = 四项之和、discount_total = 六项之和、confirmed_income = gross - discount
|
||||
- **验证: 需求 2.1, 2.2, 2.3, 8.3**
|
||||
|
||||
- [x] 4.3 编写属性测试:非 all 区域现金流为零
|
||||
- **Property 4: 非 all 区域现金流/卡消费/充值为零**
|
||||
- 生成器:随机结算单列表 + 全局现金流数据
|
||||
- 验证:transform 输出中 `area_code ≠ 'all'` 的行,所有现金流/卡消费/充值字段 = 0
|
||||
- **验证: 需求 2.5**
|
||||
|
||||
- [x] 4.4 编写属性测试:ETL 输出完整性与聚合正确性
|
||||
- **Property 5: ETL 输出完整性与聚合正确性**
|
||||
- 生成器:随机结算单列表
|
||||
- 验证:输出恰好 9 行,all 行收入/优惠 = hallA~ktv 之和,hall 行 = hallA~ktv 之和
|
||||
- **验证: 需求 2.7, 2.8, 8.4**
|
||||
|
||||
- [x] 4.5 编写属性测试:ETL 幂等性
|
||||
- **Property 6: ETL 幂等性(delete-before-insert)**
|
||||
- 生成器:随机结算单列表
|
||||
- 验证:对同一输入运行两次 transform,两次输出完全相同
|
||||
- **验证: 需求 3.4**
|
||||
|
||||
- [x] 4.6 编写属性测试:settle_type 过滤
|
||||
- **Property 7: settle_type 过滤**
|
||||
- 生成器:包含不同 settle_type 值的结算单列表
|
||||
- 验证:仅 `settle_type IN (1, 3)` 的记录影响输出金额
|
||||
- **验证: 需求 3.6**
|
||||
|
||||
- [x] 4.7 编写单元测试:ETL transform 边界条件
|
||||
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_finance_area_daily.py`
|
||||
- 覆盖:discount_gift_card 口径验证、营业日切点边界、未知区域名称处理、空结算单输入
|
||||
- _Requirements: 3.1, 3.2, 3.9_
|
||||
|
||||
- [x] 5. DDL:创建 dws_finance_board_cache 表与 RLS 视图
|
||||
- [x] 5.1 编写 DDL 迁移脚本
|
||||
- 创建 `dws.dws_finance_board_cache` 表(overview 8 项 + 日期范围 + 指纹 + 元数据)
|
||||
- 添加 UNIQUE 约束 `(site_id, time_range, area_code)`
|
||||
- 创建 RLS 视图 `v_dws_finance_board_cache`
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4_
|
||||
|
||||
- [x] 6. ETL:DWS_FINANCE_BOARD_CACHE 任务与属性测试
|
||||
- [x] 6.1 创建 `apps/etl/connectors/feiqiu/tasks/dws/finance_board_cache.py`
|
||||
- 继承 `BaseDwsTask`
|
||||
- `extract`:遍历 5 个已完成周期 × 9 个区域 = 45 组合,从 `dws_finance_area_daily` 读取日粒度行
|
||||
- `transform`:实现 `compute_fingerprint`(MD5),与缓存表对比,标记需重算的组合
|
||||
- `load`:对需重算的组合从日粒度表 SUM 后 upsert 到缓存表(`ON CONFLICT DO UPDATE`)
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_
|
||||
|
||||
- [x] 6.2 编写属性测试:数据指纹确定性与缓存失效
|
||||
- **Property 8: 数据指纹确定性与缓存失效**
|
||||
- 生成器:随机日粒度行列表
|
||||
- 验证:相同输入产生相同指纹;修改任意行的 gross_amount 或 discount_total 后指纹变化
|
||||
- **验证: 需求 5.2, 5.3, 5.4**
|
||||
|
||||
- [x] 6.3 编写属性测试:当期周期不写入缓存
|
||||
- **Property 9: 当期周期不写入缓存**
|
||||
- 生成器:随机 time_range 从 {month, week, quarter}
|
||||
- 验证:ETL 缓存任务不为当期 time_range 写入缓存记录
|
||||
- **验证: 需求 5.7**
|
||||
|
||||
- [x] 6.4 编写单元测试:缓存任务边界条件
|
||||
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_finance_board_cache.py`
|
||||
- 覆盖:指纹变化检测、空数据处理、upsert 幂等性
|
||||
- _Requirements: 5.2, 5.3, 5.4_
|
||||
|
||||
- [x] 7. 检查点 — ETL 层验证
|
||||
- 运行 ETL 属性测试:`cd C:\NeoZQYY && pytest tests/test_finance_area_daily_props.py tests/test_finance_board_cache_props.py -v`
|
||||
- 运行 ETL 单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/test_finance_area_daily.py tests/unit/test_finance_board_cache.py -v`
|
||||
- 确保 Property 3-9 全部通过
|
||||
- ask the user if questions arise.
|
||||
|
||||
- [x] 8. 后端:改造 fdw_queries.py 查询函数与属性测试
|
||||
- [x] 8.1 新增/改造 `apps/backend/app/fdw_queries.py` 中的查询函数
|
||||
- 新增 `get_finance_overview_area(conn, site_id, start_date, end_date, area_code)` — 从 `v_dws_finance_area_daily` 按 area_code 聚合 overview 8 项指标
|
||||
- 新增 `get_finance_revenue_area(conn, site_id, start_date, end_date, area_code)` — 从 `v_dws_finance_area_daily` 按 area_code 聚合 revenue 板块数据
|
||||
- 新增 `get_finance_board_cache(conn, site_id, time_range, area_code)` — 查询 `v_dws_finance_board_cache` 缓存
|
||||
- 新增 `set_finance_board_cache(conn, site_id, time_range, area_code, data)` — 写入/更新缓存
|
||||
- 使用 `SET LOCAL app.current_site_id` 保证 RLS 隔离
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.5, 6.6_
|
||||
|
||||
- [x] 8.2 编写属性测试:查询路由正确性
|
||||
- **Property 10: 查询路由正确性**
|
||||
- 生成器:随机查询参数(time_range、area_code)+ mock 数据库返回
|
||||
- 验证:已完成周期+缓存存在→返回缓存;缓存不存在→SUM+写缓存;当期→直接 SUM 不查缓存
|
||||
- **验证: 需求 6.1, 6.2, 6.3, 9.4**
|
||||
|
||||
- [x] 8.3 编写属性测试:区域过滤行为
|
||||
- **Property 11: 区域过滤行为**
|
||||
- 生成器:随机 area_code ≠ 'all' + mock 数据
|
||||
- 验证:recharge 返回 null;cashflow/expense/coach_analysis 使用全局数据
|
||||
- **验证: 需求 6.7, 6.8**
|
||||
|
||||
- [x] 8.4 编写属性测试:revenue 固定项数
|
||||
- **Property 12: revenue 固定项数**
|
||||
- 生成器:随机查询参数 + mock 数据
|
||||
- 验证:discount_items 恰好 5 项,channel_items 恰好 3 项
|
||||
- **验证: 需求 7.3, 7.4**
|
||||
|
||||
- [x] 8.5 编写属性测试:area≠all 时 overview 覆盖逻辑
|
||||
- **Property 13: area≠all 时 overview 覆盖逻辑**
|
||||
- 生成器:随机 area_code ≠ 'all' + mock revenue 数据
|
||||
- 验证:overview.occurrence = revenue.total_occurrence,overview.discount = revenue.discount_total,overview.confirmed_revenue = revenue.confirmed_total
|
||||
- **验证: 需求 7.6**
|
||||
|
||||
- [x] 8.6 编写单元测试:fdw_queries 查询正确性
|
||||
- 测试文件:`apps/backend/tests/unit/test_fdw_queries_area.py`
|
||||
- 覆盖:SQL 正确性、area_code 过滤、缓存命中/未命中、RLS 隔离
|
||||
- _Requirements: 6.1, 6.2, 6.5, 6.6_
|
||||
|
||||
- [x] 9. 后端:改造 board_service.py 缓存查询逻辑
|
||||
- [x] 9.1 改造 `apps/backend/app/board_service.py` 的 `get_finance_board` 函数
|
||||
- 新增缓存查询逻辑:已完成周期先查 `get_finance_board_cache`,命中直接返回
|
||||
- 缓存未命中:从日粒度表 SUM 计算,写入缓存后返回
|
||||
- 当期周期:直接从日粒度表 SUM,不查缓存
|
||||
- `_build_overview` 改为调用 `get_finance_overview_area`(传入 area_code)
|
||||
- `_build_revenue` 改为调用 `get_finance_revenue_area`(传入 area_code)
|
||||
- `_build_cashflow` 不变(始终用全局数据)
|
||||
- `area≠all` 时 overview 覆盖逻辑保留(occurrence/discount/confirmedRevenue = revenue 对应值)
|
||||
- `area≠all` 时 recharge 返回 null
|
||||
- compare=1 时对上期执行同样缓存/日粒度逻辑
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 7.1, 7.2, 7.5, 7.6_
|
||||
|
||||
- [x] 9.2 编写属性测试:area=all 回归一致性
|
||||
- **Property 14: area=all 回归一致性**
|
||||
- 生成器:随机日期范围 + mock 新旧逻辑数据
|
||||
- 验证:area=all 时新逻辑的 overview 8 项指标与旧逻辑完全一致
|
||||
- **验证: 需求 9.1**
|
||||
|
||||
- [x] 9.3 编写单元测试:board_service 改造
|
||||
- 测试文件:`apps/backend/tests/unit/test_board_service_area.py`
|
||||
- 覆盖:缓存命中/未命中路径、覆盖逻辑、环比计算、降级行为(无数据返回全零)
|
||||
- _Requirements: 6.1, 6.2, 6.3, 7.6, 9.1_
|
||||
|
||||
- [x] 10. 检查点 — 后端层验证
|
||||
- 运行后端属性测试:`cd C:\NeoZQYY && pytest tests/test_board_service_props.py -v`
|
||||
- 运行后端单元测试:`cd apps/backend && pytest tests/unit/test_fdw_queries_area.py tests/unit/test_board_service_area.py -v`
|
||||
- 确保 Property 10-14 全部通过
|
||||
- ask the user if questions arise.
|
||||
|
||||
- [x] 11. 历史数据回填脚本
|
||||
- [x] 11.1 编写回填脚本 `scripts/ops/backfill_finance_area_daily.py`
|
||||
- 对已有日期范围批量调用 `FinanceAreaDailyTask.transform` 逻辑
|
||||
- 支持指定 site_id 和日期范围参数
|
||||
- 回填完成后触发 `DWS_FINANCE_BOARD_CACHE` 重算所有已完成周期缓存
|
||||
- _Requirements: 2.7, 3.4, 5.1_
|
||||
|
||||
- [x] 12. 前后端联调与集成验证
|
||||
- [x] 12.1 启动后端服务,使用测试库验证各端点完整请求-响应链路
|
||||
- 使用真实 FDW 连接验证 SQL 查询正确性
|
||||
- 验证 JSON 响应结构与 Schema 定义一致(camelCase 序列化)
|
||||
- 验证数据隔离(`SET LOCAL app.current_site_id`)在真实请求中生效
|
||||
- _Requirements: 7.1, 7.2, 9.1_
|
||||
- [x] 12.2 运行 144 组合全量验证脚本
|
||||
- 执行 `scripts/ops/validate_board_finance.py`(8 time_range × 9 area_code × 2 compare)
|
||||
- 确认 area=all 时所有板块数据与重构前完全一致(回归测试)
|
||||
- 确认 area≠all 时 discountRate 不出现 400%+ 异常值
|
||||
- 确认已完成周期第二次请求命中缓存
|
||||
- _Requirements: 9.1, 9.2, 9.3, 9.4_
|
||||
- [x] 12.3 前端联调验证
|
||||
- 确认小程序财务看板页能正确调用 API 并渲染数据(前端零改动)
|
||||
- 验证空数据/降级场景下前端不崩溃
|
||||
- 如前端页面尚未开发,记录待联调清单供后续任务使用
|
||||
- _Requirements: 7.1, 7.2_
|
||||
|
||||
- [x] 13. 数据库变更审计与 DDL 合并
|
||||
- [x] 13.1 审计本次实现中对数据库的所有改动
|
||||
- 检查新建表(dws_finance_area_daily、dws_finance_board_cache)、RLS 视图、FDW 映射变更
|
||||
- _Requirements: 2.6, 4.1_
|
||||
- [x] 13.2 执行迁移脚本到测试库
|
||||
- 验证新表和索引已正确创建(使用 BD 手册中的验证 SQL)
|
||||
- _Requirements: 2.6, 4.1_
|
||||
- [x] 13.3 合并到主 DDL 基线文件
|
||||
- ETL 库 → `docs/database/ddl/etl_feiqiu__dws.sql`
|
||||
- FDW → `db/fdw/` 对应文件
|
||||
- _Requirements: 2.6, 4.1_
|
||||
- [x] 13.4 编写回滚脚本(逆序 DROP TABLE/VIEW)
|
||||
- _Requirements: 2.6, 4.1_
|
||||
|
||||
- [x] 14. BD 手册更新
|
||||
- [x] 14.1 创建 BD 手册
|
||||
- ETL 库 → `apps/etl/connectors/feiqiu/docs/database/dws/main/BD_manual_dws_finance_area_daily.md`
|
||||
- ETL 库 → `apps/etl/connectors/feiqiu/docs/database/dws/main/BD_manual_dws_finance_board_cache.md`
|
||||
- FDW → `docs/database/BD_Manual_fdw_finance_area.md`
|
||||
- 每份手册包含:字段明细、约束与索引、验证 SQL(≥3 条)、兼容性影响、回滚策略
|
||||
- 记录变更原因、影响范围
|
||||
- _Requirements: 2.1, 2.6, 4.1, 4.4_
|
||||
|
||||
- [x] 15. 文档同步更新
|
||||
- [x] 15.1 更新 ETL 任务文档
|
||||
- 在 `docs/etl_tasks/` 新增 DWS_FINANCE_AREA_DAILY 和 DWS_FINANCE_BOARD_CACHE 任务文档
|
||||
- _Requirements: 3.7, 3.8, 5.5, 5.6_
|
||||
- [x] 15.2 更新后端 README
|
||||
- 在 `apps/backend/README.md` 更新 board_service 和 fdw_queries 模块摘要
|
||||
- _Requirements: 6.5, 6.6_
|
||||
- [x] 15.3 更新文档地图
|
||||
- 在 `docs/DOCUMENTATION-MAP.md` 新增本次模块条目(BD 手册、ETL 任务文档)
|
||||
- _Requirements: 2.6, 4.1_
|
||||
|
||||
- [x] 16. 变更审计收口
|
||||
- [x] 16.1 触发审计子代理(audit-writer)执行完整审计流程
|
||||
- 确认 `.kiro/state/.audit_state.json` 中 `audit_required` 已标记
|
||||
- 审计子代理自动完成:变更审计记录(docs/audit/changes/)、AI_CHANGELOG、CHANGE 标记注释
|
||||
- 高风险路径:`tasks/`(ETL)、`apps/backend/app/`(后端)、`packages/shared/`(共享包)、`db/`(数据库)
|
||||
- _Requirements: 全部_
|
||||
- [x] 16.2 验证审计产物完整性
|
||||
- 确认 `docs/audit/changes/<YYYY-MM-DD>__board-finance-dws-area-refactor.md` 已生成
|
||||
- 确认涉及的高风险文件均有 AI_CHANGELOG 条目
|
||||
- 确认逻辑变更处有 CHANGE 标记注释(含日期、Prompt、直接原因)
|
||||
- _Requirements: 全部_
|
||||
- [x] 16.3 刷新审计一览表
|
||||
- 执行 `python scripts/audit/gen_audit_dashboard.py`
|
||||
- 确认新记录出现在 `docs/audit/audit_dashboard.md`
|
||||
- _Requirements: 全部_
|
||||
|
||||
- [x] 17. 最终检查点 — 全量验证
|
||||
- 运行 Monorepo 属性测试:`cd C:\NeoZQYY && pytest tests/ -v`
|
||||
- 运行 ETL 单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit -v`
|
||||
- 运行后端单元测试:`cd apps/backend && pytest tests/ -v`
|
||||
- 确保所有属性测试(Property 1-14)和单元测试全部通过
|
||||
- 确保 DDL 迁移已合并到主基线
|
||||
- 确保 BD 手册已同步更新
|
||||
- 确保后端 README、文档地图均已更新
|
||||
- 确保变更审计记录已生成、AI_CHANGELOG 已写入、审计一览表已刷新
|
||||
- ask the user if questions arise.
|
||||
|
||||
## 备注
|
||||
|
||||
- 标记 `*` 的子任务为可选(属性测试/单元测试),可跳过以加速 MVP
|
||||
- 每个任务引用了具体的需求编号以确保可追溯性
|
||||
- 属性测试验证通用正确性属性(Property 1-14),单元测试验证具体边界条件
|
||||
- 检查点任务确保增量验证,避免问题累积
|
||||
- 本 spec 为跨系统类(ETL + 后端 + DB + 共享包),收尾阶段覆盖步骤 1-6
|
||||
- 设计文档使用 Python,所有实现和测试均使用 Python
|
||||
Reference in New Issue
Block a user