Files
Neo-ZQYY/.kiro/specs/gift-card-breakdown/design.md

20 KiB
Raw Blame History

Design Document: 赠送卡矩阵细分数据 (gift-card-breakdown)

Overview

本设计解决 BOARD-3 财务看板赠送卡 3×4 矩阵中细分单元格全部返回 0 的问题。根因是 DWS 层 dws_finance_recharge_summary 仅存储赠送卡总额,未按卡类型(酒水卡/台费卡/抵用券)拆分。

改动贯穿全栈数据链路DDL 新增 6 个字段 → ETL 拆分填充 → RLS 视图 + FDW 同步 → 后端 SQL 查询 + 接口返回 → 小程序替换 mock 数据。

关键约束:消费行无法按卡类型拆分(上游飞球 API 的 dwd_settlement_head.gift_card_amount 仅提供总额),消费行细分列保持 0。

Architecture

数据流向

flowchart TD
    subgraph DWD["DWD 层(数据源)"]
        DCA["dim_member_card_account<br/>card_type_id + balance"]
        DRO["dwd_recharge_order<br/>point_amount"]
        DSH["dwd_settlement_head<br/>gift_card_amount仅总额"]
    end

    subgraph ETL["ETLFinanceRechargeTask"]
        ECB["_extract_card_balances()<br/>按 card_type_id 分组余额"]
        EGR["_extract_gift_recharge_breakdown()<br/>JOIN 按 card_type_id 分组新增"]
        TF["transform()<br/>合并 6 个新字段到 record"]
    end

    subgraph DWS["DWS 层"]
        DFS["dws_finance_recharge_summary<br/>+6 新字段"]
    end

    subgraph VIEW["视图 + FDW"]
        RLS["app.v_dws_finance_recharge_summary<br/>RLS 行级安全"]
        FDW["FDW 外部表<br/>业务库映射"]
    end

    subgraph BACKEND["后端"]
        FQ["fdw_queries.get_finance_recharge()<br/>SQL SUM 6 字段"]
        BS["board_service._build_recharge()<br/>环比计算(自动适配)"]
    end

    subgraph MP["小程序"]
        BF["board-finance.ts<br/>替换 mock → API 调用"]
    end

    DCA --> ECB
    DRO -->|JOIN| EGR
    DCA -->|card_type_id| EGR
    DSH -.->|总额,无法拆分| TF
    ECB --> TF
    EGR --> TF
    TF --> DFS
    DFS --> RLS
    RLS --> FDW
    FDW --> FQ
    FQ --> BS
    BS --> BF

改动范围

层级 文件 改动类型
DDL db/etl_feiqiu/migrations/2026-xx-xx_add_gift_breakdown_fields.sql 新增
DDL 基线 docs/database/ddl/etl_feiqiu__dws.sql 修改
ETL apps/etl/connectors/feiqiu/tasks/dws/finance_recharge_task.py 修改
RLS 视图 db/zqyy_app/migrations/ 新增
FDW db/fdw/ 新增/修改
后端 apps/backend/app/services/fdw_queries.py 修改
小程序 apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts 修改
BD 手册 apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_finance_recharge_summary.md 修改

不需要修改的文件:

  • apps/backend/app/schemas/xcx_board.pyGiftCell/GiftRow/RechargePanel 已预留 liquor/table_fee/voucher 字段
  • apps/backend/app/services/board_service.py_build_recharge() 环比逻辑已遍历 gift_rows 所有 cell自动适配
  • apps/backend/app/routers/xcx_board.py — 路由无需变更

Components and Interfaces

1. DDL 迁移脚本

新增 6 个 NUMERIC(14,2) 字段到 dws.dws_finance_recharge_summary

ALTER TABLE dws.dws_finance_recharge_summary
    ADD COLUMN IF NOT EXISTS gift_liquor_balance    NUMERIC(14,2) NOT NULL DEFAULT 0,
    ADD COLUMN IF NOT EXISTS gift_table_fee_balance  NUMERIC(14,2) NOT NULL DEFAULT 0,
    ADD COLUMN IF NOT EXISTS gift_voucher_balance    NUMERIC(14,2) NOT NULL DEFAULT 0,
    ADD COLUMN IF NOT EXISTS gift_liquor_recharge    NUMERIC(14,2) NOT NULL DEFAULT 0,
    ADD COLUMN IF NOT EXISTS gift_table_fee_recharge NUMERIC(14,2) NOT NULL DEFAULT 0,
    ADD COLUMN IF NOT EXISTS gift_voucher_recharge   NUMERIC(14,2) NOT NULL DEFAULT 0;

2. ETL — FinanceRechargeTask 修改

2a. _extract_card_balances() 拆分

当前实现将三种赠送卡余额合并为 gift_balance。修改后按 card_type_id 分别返回:

# 现有常量(沿用)
CASH_CARD_TYPE_ID = 2793249295533893
GIFT_CARD_TYPE_IDS = [2791990152417157, 2793266846533445, 2794699703437125]

# card_type_id → 字段名映射
GIFT_TYPE_FIELD_MAP = {
    2794699703437125: 'gift_liquor_balance',     # 酒水卡
    2791990152417157: 'gift_table_fee_balance',   # 台费卡
    2793266846533445: 'gift_voucher_balance',     # 抵用券
}

返回值新增 3 个 keygift_liquor_balancegift_table_fee_balancegift_voucher_balance。原有 gift_balance 保持不变(向后兼容)。

2b. 新增 _extract_gift_recharge_breakdown()

通过 dwd_recharge_order JOIN dim_member_card_account(关联 tenant_member_card_idtenant_member_id)按 card_type_id 分组查询赠送金额(point_amount

GIFT_RECHARGE_FIELD_MAP = {
    2794699703437125: 'gift_liquor_recharge',     # 酒水卡
    2791990152417157: 'gift_table_fee_recharge',   # 台费卡
    2793266846533445: 'gift_voucher_recharge',     # 抵用券
}

SQL 逻辑:

SELECT dca.card_type_id, SUM(ro.point_amount) AS gift_recharge
FROM dwd.dwd_recharge_order ro
JOIN dwd.dim_member_card_account dca
    ON ro.tenant_member_card_id = dca.tenant_member_id
WHERE ro.site_id = %s
    AND {biz_date_expr} >= %s AND {biz_date_expr} <= %s
    AND dca.card_type_id IN (2794699703437125, 2791990152417157, 2793266846533445)
    AND dca.scd2_is_current = 1
    AND COALESCE(dca.is_delete, 0) = 0
GROUP BY dca.card_type_id

返回 dict{gift_liquor_recharge: Decimal, gift_table_fee_recharge: Decimal, gift_voucher_recharge: Decimal},缺失的卡类型默认 0。

2c. extract() 调用新方法

def extract(self, context):
    # ... 现有逻辑 ...
    recharge_summary = self._extract_recharge_summary(site_id, start_date, end_date)
    card_balances = self._extract_card_balances(site_id, end_date)
    gift_recharge_breakdown = self._extract_gift_recharge_breakdown(site_id, start_date, end_date)
    return {
        'recharge_summary': recharge_summary,
        'card_balances': card_balances,
        'gift_recharge_breakdown': gift_recharge_breakdown,
        'start_date': start_date, 'end_date': end_date, 'site_id': site_id,
    }

2d. transform() 合并新字段

record dict 构建时追加 6 个字段:

record = {
    # ... 现有字段 ...
    'gift_liquor_balance': self.safe_decimal(balance.get('gift_liquor_balance', 0)),
    'gift_table_fee_balance': self.safe_decimal(balance.get('gift_table_fee_balance', 0)),
    'gift_voucher_balance': self.safe_decimal(balance.get('gift_voucher_balance', 0)),
    'gift_liquor_recharge': self.safe_decimal(gift_recharge.get('gift_liquor_recharge', 0)),
    'gift_table_fee_recharge': self.safe_decimal(gift_recharge.get('gift_table_fee_recharge', 0)),
    'gift_voucher_recharge': self.safe_decimal(gift_recharge.get('gift_voucher_recharge', 0)),
}

幂等策略:沿用现有 delete-before-insertsite_id + stat_date),新字段随整行写入。

3. RLS 视图重建

app.v_dws_finance_recharge_summary 需要 CREATE OR REPLACE VIEW 更新列列表,包含 6 个新字段。RLS 策略基于 site_id,新字段自动继承。

4. FDW 外部表同步

通过 IMPORT FOREIGN SCHEMA 重新导入,幂等脚本已支持(先 DROP 再 IMPORT

5. 后端 — fdw_queries.get_finance_recharge()

SQL 新增 6 个字段的 SUM 聚合:

SELECT SUM(recharge_cash) AS actual_income,
       SUM(first_recharge_cash) AS first_charge,
       SUM(renewal_cash) AS renew_charge,
       SUM(cash_card_balance) AS card_balance,
       SUM(total_card_balance) AS all_card_balance,
       SUM(gift_card_balance) AS gift_balance_total,
       -- 新增 6 字段
       SUM(gift_liquor_balance) AS gift_liquor_balance,
       SUM(gift_table_fee_balance) AS gift_table_fee_balance,
       SUM(gift_voucher_balance) AS gift_voucher_balance,
       SUM(gift_liquor_recharge) AS gift_liquor_recharge,
       SUM(gift_table_fee_recharge) AS gift_table_fee_recharge,
       SUM(gift_voucher_recharge) AS gift_voucher_recharge
FROM app.v_dws_finance_recharge_summary
WHERE stat_date >= %s::date AND stat_date <= %s::date

gift_rows 构建逻辑更新:

gift_rows = [
    {"label": "新增", "total": _gc(gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge),
     "liquor": _gc(gift_liquor_recharge), "table_fee": _gc(gift_table_fee_recharge), "voucher": _gc(gift_voucher_recharge)},
    {"label": "消费", "total": _gc(0.0),  # 消费总额从现有字段获取
     "liquor": _gc(0.0), "table_fee": _gc(0.0), "voucher": _gc(0.0)},
    {"label": "余额", "total": _gc(gift_balance),
     "liquor": _gc(gift_liquor_balance), "table_fee": _gc(gift_table_fee_balance), "voucher": _gc(gift_voucher_balance)},
]

注意:新增行的 total 使用三个细分字段之和(而非 recharge_gift),保证 total = liquor + table_fee + voucher 恒等。

6. 后端 — _empty_recharge_data()

空默认值结构不变(已经是全 0无需修改。

7. 小程序 — board-finance.ts

替换 mock 数据为真实 API 调用。字段映射:

后端字段 小程序字段
GiftRow.liquor.value item.wine
GiftRow.table_fee.value item.table
GiftRow.voucher.value item.coupon
GiftCell.compare item.*Compare

数据转换在 API 响应处理层完成(后端返回 camelCase小程序按现有字段名映射

Data Models

DWS 表结构变更

dws.dws_finance_recharge_summary 新增字段:

字段名 类型 默认值 说明
gift_liquor_balance NUMERIC(14,2) 0 酒水卡余额(当日末快照)
gift_table_fee_balance NUMERIC(14,2) 0 台费卡余额(当日末快照)
gift_voucher_balance NUMERIC(14,2) 0 抵用券余额(当日末快照)
gift_liquor_recharge NUMERIC(14,2) 0 酒水卡新增充值(赠送部分)
gift_table_fee_recharge NUMERIC(14,2) 0 台费卡新增充值(赠送部分)
gift_voucher_recharge NUMERIC(14,2) 0 抵用券新增充值(赠送部分)

恒等式约束

  1. 余额恒等:gift_card_balance = gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance
  2. 新增恒等:recharge_gift = gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge

卡类型 ID 映射(硬编码常量)

card_type_id 类型 余额字段 新增字段
2794699703437125 酒水卡 gift_liquor_balance gift_liquor_recharge
2791990152417157 台费卡 gift_table_fee_balance gift_table_fee_recharge
2793266846533445 抵用券 gift_voucher_balance gift_voucher_recharge

现有 Schema无需修改

GiftCellPydantic

class GiftCell(CamelModel):
    value: float
    compare: str | None = None
    down: bool | None = None
    flat: bool | None = None

GiftRowPydantic

class GiftRow(CamelModel):
    label: str  # "新增" / "消费" / "余额"
    total: GiftCell
    liquor: GiftCell
    table_fee: GiftCell
    voucher: GiftCell

ETL 内部数据结构

_extract_card_balances() 返回值扩展:

{
    'cash_balance': Decimal,
    'gift_balance': Decimal,           # 保留(向后兼容)
    'total_balance': Decimal,
    'gift_liquor_balance': Decimal,    # 新增
    'gift_table_fee_balance': Decimal, # 新增
    'gift_voucher_balance': Decimal,   # 新增
}

_extract_gift_recharge_breakdown() 返回值:

{
    'gift_liquor_recharge': Decimal,
    'gift_table_fee_recharge': Decimal,
    'gift_voucher_recharge': Decimal,
}

Correctness Properties

A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.

Property 1: 余额恒等式

For any DWS 记录,gift_card_balance 的值必须等于 gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance。即对于任意随机生成的三种赠送卡余额,写入 DWS 后总额字段与细分字段之和恒等。

Validates: Requirements 1.3, 10.3

Property 2: 新增恒等式

For any DWS 记录,recharge_gift 的值必须等于 gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge。即对于任意随机生成的三种赠送卡新增金额,写入 DWS 后总额字段与细分字段之和恒等。

Validates: Requirements 1.4

Property 3: ETL 余额提取 round-trip

For any 一组 dim_member_card_account 记录(包含随机的 card_type_idbalance_extract_card_balances() 返回的各类型余额必须等于对应 card_type_idbalance 之和。当某种卡类型无记录时,对应余额为 0。

Validates: Requirements 2.1, 2.2, 2.3, 10.1

Property 4: ETL 新增提取 round-trip

For any 一组 dwd_recharge_order + dim_member_card_account 记录(包含随机的 card_type_idpoint_amount_extract_gift_recharge_breakdown() 返回的各类型新增金额必须等于对应 card_type_idpoint_amount 之和。当某种卡类型无充值记录时,对应新增为 0。

Validates: Requirements 3.1, 3.2, 3.3, 10.2

Property 5: transform 正确合并细分字段

For any 随机生成的 card_balances dict含 3 个余额字段)和 gift_recharge_breakdown dict含 3 个新增字段),transform() 输出的 record 必须包含这 6 个字段且值与输入一致。当输入 dict 缺少某个 key 时,对应字段为 0。

Validates: Requirements 4.1, 4.2

Property 6: 后端接口返回正确的细分数据

For any DWS 表中的一组记录(包含随机的 6 个细分字段值),get_finance_recharge() 返回的 gift_rows 中余额行的 liquor/table_fee/voucher 值必须等于对应字段的 SUM新增行同理。

Validates: Requirements 7.2, 7.3

Property 7: 消费行细分列始终为 0

For any 调用 get_finance_recharge() 的返回结果,gift_rows 中消费行label="消费")的 liquor.valuetable_fee.valuevoucher.value 必须为 0。total.value 可以为任意非负值。

Validates: Requirements 7.4, 8.1, 8.2

Property 8: 环比计算对新字段正确适配

For any 两组随机生成的当期和上期 DWS 数据,_build_recharge()compare=1 时,gift_rows 中每个 cell 的 compare 字段必须等于 calc_compare(当期值, 上期值) 的结果。

Validates: Requirements 7.6

Error Handling

ETL 层

场景 处理策略
DWD 层某种卡类型无记录 对应字段写入 0dict.get() 默认值)
_extract_gift_recharge_breakdown() SQL 查询失败 由 ETL 框架统一捕获异常,任务标记失败,不写入脏数据
dim_member_card_account 中出现未知 card_type_id 忽略(仅处理 GIFT_CARD_TYPE_IDS 中的三种)
余额恒等式不满足 ETL 不做运行时校验(由数据质量检查任务事后验证)

后端层

场景 处理策略
FDW 查询失败 _build_recharge() 捕获异常,降级返回 None(现有逻辑)
查询结果为空 返回 _empty_recharge_data()(全 0 结构)
新字段在视图中不存在(迁移未执行) SQL 报错,降级为 None

小程序层

场景 处理策略
API 返回错误或超时 展示加载失败提示,不显示 mock 数据
API 返回的 gift_rows 结构不完整 使用默认值 0 填充缺失字段

Testing Strategy

属性测试Property-Based Testing

使用 hypothesis 库(项目已有依赖),测试文件放置在 tests/ 目录Monorepo 级属性测试)。

每个属性测试最少 100 次迭代,使用 @settings(max_examples=100) 配置。

测试文件

tests/test_gift_card_breakdown_properties.py

属性测试清单

属性 测试方法 生成器
P1: 余额恒等式 生成随机三种余额,验证总额 = 三者之和 st.decimals(min_value=0, max_value=999999, places=2)
P2: 新增恒等式 生成随机三种新增,验证总额 = 三者之和 同上
P3: ETL 余额提取 生成随机 card_account 行mock DB验证提取结果 st.lists(st.fixed_dictionaries({...}))
P4: ETL 新增提取 生成随机 recharge_order + card_accountmock DB验证提取结果 同上
P5: transform 合并 生成随机 extracted dict验证 record 包含正确字段 st.fixed_dictionaries({...})
P6: 后端接口细分数据 生成随机 DWS 行mock FDW 查询,验证 gift_rows st.fixed_dictionaries({...})
P7: 消费行始终为 0 生成随机数据,验证消费行细分列 = 0 任意输入
P8: 环比适配 生成随机当期/上期数据,验证 compare 计算 st.tuples(amount_strategy, amount_strategy)

标签格式:# Feature: gift-card-breakdown, Property {N}: {title}

单元测试

使用 pytest,测试文件放置在各模块的 tests/ 目录。

ETL 单元测试

apps/etl/connectors/feiqiu/tests/unit/test_finance_recharge_task.py

测试用例 说明
test_extract_card_balances_with_all_types 三种卡类型都有记录时返回正确余额
test_extract_card_balances_missing_type 某种卡类型无记录时返回 0
test_extract_gift_recharge_breakdown_basic 基本新增拆分正确
test_extract_gift_recharge_breakdown_empty 无充值记录时全部返回 0
test_transform_includes_new_fields transform 输出包含 6 个新字段

后端单元测试

apps/backend/tests/test_fdw_queries_gift.py

测试用例 说明
test_get_finance_recharge_gift_breakdown 返回正确的细分数据
test_get_finance_recharge_empty 无数据时返回全 0
test_gift_rows_consumption_always_zero 消费行细分列始终为 0

集成验证(手动)

ETL 跑数后执行验证 SQL

-- 验证余额恒等式
SELECT site_id, stat_date,
       gift_card_balance,
       gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance AS sum_breakdown,
       gift_card_balance - (gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance) AS diff
FROM dws.dws_finance_recharge_summary
WHERE gift_card_balance != gift_liquor_balance + gift_table_fee_balance + gift_voucher_balance;

-- 验证新增恒等式
SELECT site_id, stat_date,
       recharge_gift,
       gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge AS sum_breakdown,
       recharge_gift - (gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge) AS diff
FROM dws.dws_finance_recharge_summary
WHERE recharge_gift != gift_liquor_recharge + gift_table_fee_recharge + gift_voucher_recharge;

-- 验证与 DWD 源数据一致
SELECT 'DWS' AS source,
       SUM(gift_liquor_balance) AS liquor,
       SUM(gift_table_fee_balance) AS table_fee,
       SUM(gift_voucher_balance) AS voucher
FROM dws.dws_finance_recharge_summary
WHERE stat_date = CURRENT_DATE
UNION ALL
SELECT 'DWD',
       SUM(CASE WHEN card_type_id = 2794699703437125 THEN balance ELSE 0 END),
       SUM(CASE WHEN card_type_id = 2791990152417157 THEN balance ELSE 0 END),
       SUM(CASE WHEN card_type_id = 2793266846533445 THEN balance ELSE 0 END)
FROM dwd.dim_member_card_account
WHERE scd2_is_current = 1 AND COALESCE(is_delete, 0) = 0
  AND card_type_id IN (2794699703437125, 2791990152417157, 2793266846533445);