包含多个会话的累积代码变更: - 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>
16 KiB
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 表结构
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):
- 从
dwd_settlement_head+dim_table按区域聚合收入和优惠字段 all行 = 所有区域之和 + 现金流/充值等全局字段(从现有dws_finance_daily_summary逻辑复用)- 各区域行 = 该区域的结算单聚合,现金流/充值字段为 0(无法按区域拆分)
- delete-before-insert 策略:
DELETE WHERE site_id=X AND stat_date=Y; INSERT 9 行
3.4 优惠按区域拆分方案
优惠字段从 dwd_settlement_head 按桌台区域直接聚合(不再从 DWS 全局表取):
-- 每张结算单通过 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 表结构
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 计算完日粒度数据后,对已完成周期的源数据计算指纹:
# 指纹 = 该时间范围内所有日粒度行的 (stat_date, gross_amount, discount_total) 的 hash
fingerprint = hashlib.md5(
json.dumps(sorted(rows, key=lambda r: r['stat_date'])).encode()
).hexdigest()
ETL 流程:
- 计算当天日粒度数据(方案 A)
- 对每个已完成周期,计算新指纹
- 与缓存表中的
data_fingerprint对比 - 不一致 → 重算该周期的缓存(从日粒度表 SUM)
- 一致 → 跳过
五、后端查询改造
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_daily(area_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=YSUM - revenue:从
dws_finance_area_daily WHERE area_code=YSUM,构建 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~1;area≠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 行后重新插入)
计算步骤:
- 从
dwd_settlement_head+dim_table按区域聚合收入和优惠 - 从现有
dws_finance_daily_summary取全局现金流/充值/卡消费字段 - 构建 9 行(all + 8 个区域),all 行 = 各区域之和 + 全局字段
- 写入
dws_finance_area_daily
6.2 新增任务:DWS_FINANCE_BOARD_CACHE
- 位置:
apps/etl/connectors/feiqiu/tasks/dws/finance_board_cache.py - 依赖:DWS_FINANCE_AREA_DAILY
- 调度:每天一次(营业日切点后)
- 策略:指纹对比,不一致则重算
计算步骤:
- 遍历已完成周期(lastMonth/lastWeek/lastQuarter/quarter3/half6)
- 对每个周期 × 9 个区域,计算源数据指纹
- 与缓存表对比,不一致则从
dws_finance_area_dailySUM 重算 - 写入/更新
dws_finance_board_cache
6.3 区域映射共享配置
将区域映射从 Python 硬编码抽成共享配置,ETL 和后端共用:
# 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 | ETL:DWS_FINANCE_AREA_DAILY 任务 | 3h |
| T4 | DDL:创建 dws_finance_board_cache + RLS 视图 | 0.5h |
| T5 | ETL:DWS_FINANCE_BOARD_CACHE 任务(含指纹机制) | 2h |
| T6 | 后端:改造 fdw_queries.py 6 个查询函数 | 3h |
| T7 | 后端:改造 board_service.py 缓存查询逻辑 | 2h |
| T8 | 验证:重跑 144 组合验证脚本 | 1h |
| T9 | 历史数据回填:对已有日期范围批量计算 | 1h |
九、风险与缓解
- 区域映射一致性:抽成共享配置(T1),ETL 和后端共用同一份映射
- 优惠按区域拆分:直接从结算单按桌台区域聚合,不做分摊。每张结算单对应一张桌台,优惠归属该桌台所在区域
- 缓存失效:数据指纹机制(T5),补录后自动检测并重算
- 营业日切点:从
.env读取,ETL 和后端共用 - 向后兼容:新表是增量,不修改现有
dws_finance_daily_summary,可并行运行验证
十、验证标准
- 144 组合全量验证脚本通过(复用
scripts/ops/validate_board_finance.py) - area=all 时数据与现有逻辑完全一致(回归测试)
- area≠all 时优惠数据合理(discountRate ≤ 1 或接近 1)
- 已完成周期缓存命中率 100%(第二次请求不触发 SUM 计算)