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

16 KiB
Raw Permalink Blame History

SPEC: 财务看板 DWS 区域维度重构

创建日期2026-03-28 前置 SPECboard-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

核心 bugarea≠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

  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 全局表取):

-- 每张结算单通过 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 流程:

  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 需要按区域过滤:

  • overviewdws_finance_area_daily WHERE area_code=Y SUM
  • revenuedws_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_dailyAPI 签名和返回数据结构完全不变

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 和后端共用:

# 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 = 所有区域之和

七、营业日切点

  • .envBUSINESS_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 计算)