# 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 计算
