# 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_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=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~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 行后重新插入）

计算步骤：
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 | 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 |

## 九、风险与缓解

1. **区域映射一致性**：抽成共享配置（T1），ETL 和后端共用同一份映射
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 计算）
