Files
Neo-ZQYY/docs/prd/specs/board-finance-dws-area-refactor.md
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 00:03:48 +08:00

376 lines
16 KiB
Markdown
Raw Permalink 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.
# SPEC: 财务看板 DWS 区域维度重构
> 创建日期2026-03-28
> 前置 SPEC`board-finance-phase2`(已完成)、`board-finance-phase2-validation`(已完成)
> 状态:待确认
> 优先级P1
---
## 一、背景与问题
当前财务看板后端 6 个板块的数据来源分散:
- overview/cashflow/recharge 从 `dws_finance_daily_summary`(全局日汇总,无区域维度)
- revenue 从 `dwd_settlement_head`(结算单级别,有桌台→区域映射)
- expense 从 `dws_finance_expense_summary` + `dws_platform_settlement`
- coach_analysis 从 `dws_assistant_salary_calc`
**核心 bug**area≠all 时,优惠查询仍从全局 DWS 取数,导致 B区发生额 ¥43,049 但优惠 ¥179,884全局优惠优惠占比 417.9%。
## 二、方案概述
两层架构:
- **方案 A原子层**:新建 `dws_finance_area_daily`,按 `(stat_date, area_code)` 日粒度存储ETL 每天计算
- **方案 B缓存层**:新建 `dws_finance_board_cache`,缓存已完成周期的聚合结果,避免重复计算
后端查询逻辑:先查缓存 → 未命中则从日粒度表实时 SUM。
## 三、方案 A — 原子层 `dws_finance_area_daily`
### 3.1 表结构
```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, -- all/hall/hallA/hallB/hallC/vip/snooker/mahjong/ktv
-- ── 收入结构(从 dwd_settlement_head 按区域聚合)──
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, -- = 四项之和
-- ── 优惠拆分(从 dwd_settlement_head 按区域聚合6 项恒等式)──
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, -- = 6 项之和
-- ── 确认收入 ──
confirmed_income NUMERIC(14,2) NOT NULL DEFAULT 0, -- = gross_amount - discount_total
-- ── 现金流(仅 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)
);
```
### 3.2 area_code 枚举
| area_code | 含义 | 物理区域映射 |
|-----------|------|-------------|
| all | 全部区域 | 所有桌台 |
| hall | 大厅A+B+C+包厢+斯诺克+麻将+团建) | 同 all历史兼容 |
| hallA | A区 | site_table_area_name = 'A区' |
| hallB | B区 | site_table_area_name = 'B区' |
| hallC | C区 | 'C区'/'TV台'/'美洲豹赛台' |
| vip | 台球包厢 | 'VIP包厢' |
| snooker | 斯诺克 | '斯诺克区' |
| mahjong | 麻将房 | '麻将房'/'M7'/'M8'/'666'/'发财' |
| ktv | 团建房 | 'K包'/'k包活动区'/'幸会158' |
### 3.3 ETL 计算逻辑
每次 ETL 运行时,对当天(按营业日切点 `BUSINESS_DAY_START_HOUR=8`
1.`dwd_settlement_head` + `dim_table` 按区域聚合收入和优惠字段
2. `all` 行 = 所有区域之和 + 现金流/充值等全局字段(从现有 `dws_finance_daily_summary` 逻辑复用)
3. 各区域行 = 该区域的结算单聚合,现金流/充值字段为 0无法按区域拆分
4. delete-before-insert 策略:`DELETE WHERE site_id=X AND stat_date=Y; INSERT 9 行`
### 3.4 优惠按区域拆分方案
优惠字段从 `dwd_settlement_head` 按桌台区域直接聚合(不再从 DWS 全局表取):
```sql
-- 每张结算单通过 table_id → dim_table.site_table_area_name → area_code 映射
SELECT area_code,
SUM(coupon_amount) AS discount_groupbuy, -- 团购券抵扣
SUM(member_discount_amount) AS discount_vip, -- 会员折扣
SUM(adjust_amount) AS discount_manual_raw, -- 手动调整(含大客户优惠)
SUM(gift_card_amount) AS discount_gift_card, -- 赠送卡抵扣(= balance_amount - recharge_card_amount
SUM(rounding_amount) AS discount_rounding -- 抹零
FROM dwd_settlement_head h
JOIN dim_table t ON h.table_id = t.table_id AND t.scd2_is_current = 1
WHERE settle_type IN (1, 3)
AND biz_date(create_time, 8) = :stat_date
GROUP BY area_code
```
这样每个区域的优惠就是该区域桌台上实际发生的优惠,不存在分摊问题。
### 3.5 优惠按区域拆分 — 样例验证
以 2026-03 本月 B区为例对比修复前后
| 指标 | 修复前(全局优惠) | 修复后(按结算单归属) |
|------|---------------------|---------------------|
| B区发生额 | ¥43,049 | ¥43,050 |
| B区优惠 | ¥179,884 | ¥31,327 |
| 优惠占比 | 417.9% | 72.8% |
B区 72.8% 的优惠率仍偏高,但查看具体结算单后确认是真实业务现象:
- Top 8 优惠单中 7 张是会员折扣 = 台费全额(会员用储值卡全额抵扣)
- 手动调整和抹零在 B区几乎为 0
- 不存在"整单优惠被错误归属到小区域"的问题——每张结算单对应一张桌台
结论:按结算单直接归属区域是正确的,不需要分摊。
注意:`discount_gift_card` 的口径是赠送卡消费金额ETL 中 `gift_card_consume_amount`),不是结算单的 `gift_card_amount`(赠送卡支付金额)。新表需要复用现有 ETL 的同一计算逻辑。
## 四、方案 B — 缓存层 `dws_finance_board_cache`
### 4.1 表结构
```sql
CREATE TABLE dws.dws_finance_board_cache (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
time_range VARCHAR(20) NOT NULL, -- month/lastMonth/week/lastWeek/quarter/lastQuarter/quarter3/half6
area_code VARCHAR(20) NOT NULL, -- all/hall/hallA/.../ktv
start_date DATE NOT NULL, -- 当期起始日
end_date DATE NOT NULL, -- 当期截止日
prev_start_date DATE, -- 上期起始日(环比用)
prev_end_date DATE, -- 上期截止日
-- ── 经营一览overview──
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), -- 源数据 hash用于检测补录导致的数据变化
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)
);
```
### 4.2 缓存策略
| 时间范围 | 缓存行为 | 失效条件 |
|---------|---------|---------|
| month/week/quarter | 不缓存(当期数据每天在变) | — |
| lastMonth/lastWeek/lastQuarter | 缓存(已完成周期) | 数据指纹变化 |
| quarter3/half6 | 缓存(不含本月) | 数据指纹变化 |
### 4.3 数据指纹机制
每次 ETL 计算完日粒度数据后,对已完成周期的源数据计算指纹:
```python
# 指纹 = 该时间范围内所有日粒度行的 (stat_date, gross_amount, discount_total) 的 hash
fingerprint = hashlib.md5(
json.dumps(sorted(rows, key=lambda r: r['stat_date'])).encode()
).hexdigest()
```
ETL 流程:
1. 计算当天日粒度数据(方案 A
2. 对每个已完成周期,计算新指纹
3. 与缓存表中的 `data_fingerprint` 对比
4. 不一致 → 重算该周期的缓存(从日粒度表 SUM
5. 一致 → 跳过
## 五、后端查询改造
### 5.1 查询流程
```
请求: GET /api/xcx/board/finance?time=X&area=Y&compare=Z
1. 判断 time_range 是否为已完成周期
2. 已完成周期 → 查 dws_finance_board_cache
- 命中 → 直接返回缓存数据
- 未命中 → 从日粒度表 SUM写入缓存后返回
3. 当期周期 → 从 dws_finance_area_daily SUM
4. compare=1 → 对上期也执行同样逻辑,然后 calc_compare
```
### 5.2 各板块数据来源改造
| 板块 | 当前来源 | 改造后来源 |
|------|---------|-----------|
| overview | dws_finance_daily_summary全局 | dws_finance_area_daily按 area_code 过滤) |
| recharge | dws_finance_recharge_summary + dws_finance_daily_summary | 不变(仅 area=all 时显示) |
| revenue | dwd_settlement_head实时查 DWD+ dws_finance_daily_summary优惠/渠道) | dws_finance_area_daily收入+优惠+渠道全部预计算) |
| cashflow | dws_finance_daily_summary全局 | dws_finance_area_dailyarea_code='all',现金流无法按区域拆分) |
| expense | dws_finance_expense_summary + dws_platform_settlement | 不变(仅 area=all 时显示) |
| coach_analysis | dws_assistant_salary_calc | 不变(仅 area=all 时显示) |
### 5.3 area≠all 时的行为
area≠all 时,只有 overview 和 revenue 需要按区域过滤:
- overview`dws_finance_area_daily WHERE area_code=Y` SUM
- revenue`dws_finance_area_daily WHERE area_code=Y` SUM构建 structure_rows/discount_items/price_items/channel_items
- cashflow/expense/coach_analysis仍用全局数据前端隐藏这些板块但后端仍返回
- recharge返回 null
### 5.4 接口契约(硬约束 — 前端零改动)
本次重构仅改变后端数据来源(从实时查 DWD/DWS → 查预计算的 `dws_finance_area_daily`**API 签名和返回数据结构完全不变**。
#### 5.4.1 API 签名不变
```
GET /api/xcx/board/finance?time={FinanceTimeEnum}&area={AreaFilterEnum}&compare={0|1}
Authorization: Bearer {token}
Response: FinanceBoardResponse (response_model_exclude_none=True)
```
#### 5.4.2 返回结构不变Pydantic Schema 不改)
以下 Schema 类保持原样,不新增、不删除、不改名任何字段:
| Schema 类 | 说明 |
|-----------|------|
| `FinanceBoardResponse` | 顶层overview + recharge? + revenue + cashflow + expense + coach_analysis |
| `OverviewPanel` | 8 项核心指标 + 8 组环比字段 |
| `RechargePanel` | 储值卡 5 指标 + 赠送卡 3×4 矩阵 + 全卡余额 |
| `RevenuePanel` | structure_rows + price_items + discount_items + channel_items + 总计 |
| `CashflowPanel` | consume_items + recharge_items + total |
| `ExpensePanel` | 4 组 items + total |
| `CoachAnalysisPanel` | basic + incentive各含 rows + 总计) |
#### 5.4.3 字段值语义不变
| 字段 | 语义约束 |
|------|---------|
| `overview.discountRate` | area=all 时 0~1area≠all 时可能 > 1区域级优惠占比 |
| `overview.occurrence/discount/confirmedRevenue` | area≠all 时 = revenue 板块的对应值(后端覆盖逻辑保留) |
| `recharge` | area≠all 时为 null |
| `revenue.discount_items` | 固定 5 项(团购/会员折扣/手动调整/赠送卡/其他) |
| `revenue.channel_items` | 固定 3 项(储值卡结算冲销/现金线上支付/团购核销) |
| `cashflow.total` | ≥ SUM(consume_items) + SUM(recharge_items)(可能包含额外项) |
| 环比字段 | compare=1 时非空("X.X%"/"持平"/"新增"compare=0 时为 null |
#### 5.4.4 前端验证清单
重构完成后,用现有 `scripts/ops/validate_board_finance.py` 跑 39 组合验证:
- area=all 时所有板块数据与重构前完全一致(回归测试)
- area≠all 时优惠数据合理discountRate 不再出现 400%+ 的异常值)
- 环比数据基于该区域的历史数据(不是全局对比)
## 六、ETL 任务设计
### 6.1 新增任务DWS_FINANCE_AREA_DAILY
- 位置:`apps/etl/connectors/feiqiu/tasks/dws/finance_area_daily.py`
- 依赖DWD_LOAD_FROM_ODS结算单已入 DWD
- 调度:每小时(与现有 DWS_FINANCE_DAILY 同频)
- 策略delete-before-insert按 site_id + stat_date 删除 9 行后重新插入)
计算步骤:
1.`dwd_settlement_head` + `dim_table` 按区域聚合收入和优惠
2. 从现有 `dws_finance_daily_summary` 取全局现金流/充值/卡消费字段
3. 构建 9 行all + 8 个区域all 行 = 各区域之和 + 全局字段
4. 写入 `dws_finance_area_daily`
### 6.2 新增任务DWS_FINANCE_BOARD_CACHE
- 位置:`apps/etl/connectors/feiqiu/tasks/dws/finance_board_cache.py`
- 依赖DWS_FINANCE_AREA_DAILY
- 调度:每天一次(营业日切点后)
- 策略:指纹对比,不一致则重算
计算步骤:
1. 遍历已完成周期lastMonth/lastWeek/lastQuarter/quarter3/half6
2. 对每个周期 × 9 个区域,计算源数据指纹
3. 与缓存表对比,不一致则从 `dws_finance_area_daily` SUM 重算
4. 写入/更新 `dws_finance_board_cache`
### 6.3 区域映射共享配置
将区域映射从 Python 硬编码抽成共享配置ETL 和后端共用:
```python
# packages/shared/src/neozqyy_shared/area_mapping.py
AREA_LABEL_MAP = {
"hallA": ["A区"],
"hallB": ["B区"],
"hallC": ["C区", "TV台", "美洲豹赛台"],
"vip": ["VIP包厢"],
"snooker": ["斯诺克区"],
"mahjong": ["麻将房", "M7", "M8", "666", "发财"],
"ktv": ["K包", "k包活动区", "幸会158"],
}
# hall = 所有区域之和(不含 all
# all = 所有区域之和
```
## 七、营业日切点
- `.env``BUSINESS_DAY_START_HOUR=8`
- ETL 使用 `biz_date_sql_expr(create_time, cutoff_hour)` 计算 stat_date
- 新表同样使用此切点,确保与现有 DWS 表一致
## 八、实施计划
| 阶段 | 任务 | 预估工时 |
|------|------|---------|
| T1 | 共享区域映射配置 | 0.5h |
| T2 | DDL创建 dws_finance_area_daily + RLS 视图 | 0.5h |
| T3 | ETLDWS_FINANCE_AREA_DAILY 任务 | 3h |
| T4 | DDL创建 dws_finance_board_cache + RLS 视图 | 0.5h |
| T5 | ETLDWS_FINANCE_BOARD_CACHE 任务(含指纹机制) | 2h |
| T6 | 后端:改造 fdw_queries.py 6 个查询函数 | 3h |
| T7 | 后端:改造 board_service.py 缓存查询逻辑 | 2h |
| T8 | 验证:重跑 144 组合验证脚本 | 1h |
| T9 | 历史数据回填:对已有日期范围批量计算 | 1h |
## 九、风险与缓解
1. **区域映射一致性**抽成共享配置T1ETL 和后端共用同一份映射
2. **优惠按区域拆分**:直接从结算单按桌台区域聚合,不做分摊。每张结算单对应一张桌台,优惠归属该桌台所在区域
3. **缓存失效**数据指纹机制T5补录后自动检测并重算
4. **营业日切点**:从 `.env` 读取ETL 和后端共用
5. **向后兼容**:新表是增量,不修改现有 `dws_finance_daily_summary`,可并行运行验证
## 十、验证标准
- 144 组合全量验证脚本通过(复用 `scripts/ops/validate_board_finance.py`
- area=all 时数据与现有逻辑完全一致(回归测试)
- area≠all 时优惠数据合理discountRate ≤ 1 或接近 1
- 已完成周期缓存命中率 100%(第二次请求不触发 SUM 计算)