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