feat(backend): F1-6 sprint2 #4 储值卡余额迁移 sandbox_replay (SCD2 时光机)

新建 sandbox_replay/balance_replay.py 模块,迁移 fdw_queries.get_member_balance,
fdw_queries 改 thin wrapper 保持 5 处现有调用(chat/coach/customer x2/task_manager)
透明兼容。

数据源 dim_member_card_account 是 SCD2 维度表(原生支持时光机),sandbox 改造
关键是替换 scd2_is_current=1 过滤为 scd2_start_time + scd2_end_time 时间过滤
(ref_date+1day 边界 = 当天结束时仍 active 的版本,timestamptz 比较稳定)。

双口径 UI 走查 PASS(member=2799207363643141 葛先生,SCD2 历史余额变化样本):
- 4a live(today=2026-05-05): 储值余额 ¥6,602
- 4b sandbox=2026-04-20: 储值余额 ¥18,080(差异 1.1w+,时光机效果显著)

unit test sprint1+sprint2 累计 24/24 PASS,无回归。

附带本次 sprint 2 触发的架构级登记:
- 新建 docs/_overview/architecture-evolution-backlog.md(DWD 孤立 + Core 中间件 +
  库重组,长远架构演进 backlog)
- F1-6-tasks.md 登记 #3 累计交易笔数推迟 Sprint 3(ETL 配合新增
  total_open_table_count,因现有 total_visit_count 实算 COUNT(settle_type IN (1,3))
  含商城订单,不符 Neo "开台次数"业务语义)
- sandbox-replay-engine-spec §5.5 thin wrapper 决策原则(已在 #2 commit)

详见 docs/audit/changes/2026-05-06__f1_6_sprint2_member_balance.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Neo
2026-05-06 01:26:18 +08:00
parent 32716bc71a
commit 7b1cfadc2e
7 changed files with 299 additions and 32 deletions

View File

@@ -182,38 +182,21 @@ def get_member_info(
return result return result
@trace_service(description_zh="获取会员余额", description_en="Get member balance")
def get_member_balance( def get_member_balance(
conn: Any, site_id: int, member_ids: list[int], conn: Any, site_id: int, member_ids: list[int],
*, etl_conn: Any = None, *, etl_conn: Any = None,
) -> dict[int, Decimal]: ) -> dict[int, Decimal]:
"""thin wrapper — F1-6 sprint 2 #4 已迁移到 sandbox_replay.balance_replay。
保持原签名兼容现有 5 处调用(chat / coach / customer ×2 / task_manager)。
sandbox 时光机语义:走 SCD2 维度表的 scd2_start_time + scd2_end_time 时间过滤,
替代原 scd2_is_current=1(原仅 live 时刻有效)。
""" """
批量查询会员储值卡余额。 from app.services.sandbox_replay.balance_replay import (
get_member_balance as _replay_impl,
)
⚠️ DQ-7: 通过 tenant_member_id 关联 app.v_dim_member_card_account return _replay_impl(conn, site_id, member_ids, etl_conn=etl_conn)
取 scd2_is_current=1禁止使用 settlement_head.member_card_type_name。
返回 {member_id: balance} 映射。
"""
if not member_ids:
return {}
result: dict[int, Decimal] = {}
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
# CHANGE 2026-03-29 | 修复多卡膨胀:同一客户多张卡需 SUM 聚合
cur.execute(
"""
SELECT tenant_member_id AS member_id, SUM(balance) AS balance
FROM app.v_dim_member_card_account
WHERE tenant_member_id = ANY(%s) AND scd2_is_current = 1
GROUP BY tenant_member_id
""",
(member_ids,),
)
for row in cur.fetchall():
result[row[0]] = Decimal(str(row[1])) if row[1] is not None else Decimal("0")
return result
def get_last_visit_days( def get_last_visit_days(

View File

@@ -32,6 +32,9 @@ F1-6 沙箱时光机阶段 B 启动模块。
""" """
from app.services.sandbox_replay._decorator import runtime_aware from app.services.sandbox_replay._decorator import runtime_aware
from app.services.sandbox_replay.balance_replay import (
get_member_balance as get_member_balance_replay,
)
from app.services.sandbox_replay.consumption_replay import ( from app.services.sandbox_replay.consumption_replay import (
get_consumption_60d as get_consumption_60d_replay, get_consumption_60d as get_consumption_60d_replay,
get_last_visit_days as get_last_visit_days_replay, get_last_visit_days as get_last_visit_days_replay,
@@ -43,4 +46,5 @@ __all__ = [
"get_last_visit_days_replay", "get_last_visit_days_replay",
"get_consumption_60d_replay", "get_consumption_60d_replay",
"get_total_consume_amount_replay", "get_total_consume_amount_replay",
"get_member_balance_replay",
] ]

View File

@@ -0,0 +1,106 @@
"""会员卡余额相关 sandbox 重算实现。
F1-6 沙箱时光机阶段 B Sprint 2 #4 新建。
覆盖指标:
- P1-1 储值卡余额(get_member_balance)— sprint 2 #4
设计要点:
- **数据源是 SCD2 维度表 `dim_member_card_account`**(非 daily 累计快照),
原生支持时光机回溯,不需要走 dws daily 快照层
- live 模式下 ctx.business_date=today,SCD2 时光机过滤效果等同
`scd2_is_current=1`(假设 ETL SCD2 跑批正确)
- sandbox 模式下 ctx.business_date=sandbox_date,SCD2 时光机过滤回溯
到该日期当天结束时仍 active 的卡版本
- 多卡聚合(2026-03-29 修复多卡膨胀)保留:`SUM(balance) GROUP BY
tenant_member_id`
- NULL → Decimal('0') 沿用原 fdw_queries 行为
时光机 SQL 关键:
-- live: WHERE scd2_is_current=1
-- sandbox: WHERE scd2_start_time < (ref_date + 1 day)
-- AND (scd2_end_time IS NULL OR scd2_end_time >= (ref_date + 1 day))
-- ref_date+1day 边界 = ref_date 当天结束时仍 active 的版本
口径未拆分:
`get_member_balance` 当前直接 SUM 全部 balance 不区分储值卡 / 赠送卡。
本 sprint 保持口径不变,cash/gift 拆分若有业务需求登记到架构演进 backlog。
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from app.services.runtime_context import RuntimeContext
from app.services.sandbox_replay._decorator import runtime_aware
from app.trace.decorators import trace_service
@trace_service(
description_zh="获取会员储值卡余额(sandbox_replay)",
description_en="Get member card balance (sandbox_replay)",
)
@runtime_aware(metric="member_balance")
def get_member_balance(
conn: Any,
site_id: int,
member_ids: list[int],
*,
etl_conn: Any = None,
ctx: RuntimeContext,
) -> dict[int, Decimal]:
"""批量查询会员储值卡余额(sandbox_replay 版本)。
迁移自 fdw_queries.get_member_balance,行为完全一致(live 模式下),
新增 sandbox 时光机能力。
数据源约束(DQ-7):
通过 tenant_member_id 关联 `app.v_dim_member_card_account`,
禁止使用 settlement_head.member_card_type_name(自 2025-07-21 全为 NULL)。
多卡聚合:
同一会员可能持有多张卡(储值卡 / 赠送卡 / 台费卡 / 活动券 / 酒水卡),
需 SUM(balance) 聚合,避免多卡返回多行造成调用方 dict 覆盖丢失。
Args:
conn: zqyy_app 业务库连接
site_id: 门店 ID
member_ids: 会员 ID 列表
etl_conn: 可选,显式传 ETL 连接(便于测试 mock)
ctx: RuntimeContext(由 @runtime_aware 自动注入)
Returns:
{member_id: Decimal} 映射。无卡的 member_id 不出现在返回 dict 中
(沿用原 fdw_queries 行为)。SUM 为 NULL 时返回 Decimal('0')。
"""
if not member_ids:
return {}
from app.services.fdw_queries import _fdw_context
ref_date = ctx.business_date
result: dict[int, Decimal] = {}
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
# SCD2 时光机过滤:取 ref_date 当天结束时仍 active 的卡版本。
# ref_date+1day 作为右边界,使比较语义在 timestamptz 精度下保持稳定。
# 多卡 SUM 聚合保留(2026-03-29 修复)。
cur.execute(
"""
SELECT tenant_member_id AS member_id, SUM(balance) AS balance
FROM app.v_dim_member_card_account
WHERE tenant_member_id = ANY(%s)
AND scd2_start_time < (%s::date + INTERVAL '1 day')
AND (scd2_end_time IS NULL
OR scd2_end_time >= (%s::date + INTERVAL '1 day'))
GROUP BY tenant_member_id
""",
(member_ids, ref_date, ref_date),
)
for row in cur.fetchall():
result[row[0]] = (
Decimal(str(row[1])) if row[1] is not None else Decimal("0")
)
return result

View File

@@ -0,0 +1,49 @@
# 架构演进 Backlog(长远)
> 创建日期:2026-05-06
> 状态:**backlog,等优先级排期**(不在当前 F1-6 范围)
> 记录人:Neo + Claude(F1-6 Sprint 2 #3 调研触发)
## 一、核心方向
**DWD 层孤立 + Core 做连接器中间件,Core 之上的全部上游(DWS / 指数 / RLS 视图等)在所有连接器之间统一规范。**
## 二、目标
1. 后期可能接入多个连接器(与 `feiqiu` 平行的其他平台,不同字段/不同设计)
2. **DWD 层归属于各连接器自身**(字段/口径可异),保留为各连接器原始数据落地层
3. **Core 层作为中间件**:
- 下游对接各连接器的 DWD 层(吸收差异)
- 上游输出**统一规范**的字段/语义
4. **Core 之上的所有层**(DWS、指数计算、RLS 视图、ETL 任务输出表等)结构、字段、设计、设置在所有连接器之间**完全一致**
5. 后端 / 小程序 / admin-web / tenant-admin 仅依赖 Core 之上的统一层,不感知具体连接器
## 三、牵连(待逐一决断)
记录所有触发的牵连项,推进时逐一对齐:
| # | 牵连项 | 说明 | 当前状态 |
|---|------|------|---------|
| 1 | 库结构重组(连接器粒度) | 当前每店铺一个 ETL 库(`etl_feiqiu` / `test_etl_feiqiu`),Neo 指示**至少每连接器一个库**,Core 后的全部上游应统一到一个共享库集中管理 | 待设计 |
| 2 | DWS 字段名 vs 实算口径不一致 | F1-6 Sprint 2 #3 调研发现 `dws_member_consumption_summary.total_visit_count` 字段名"累计到店次数",实算是 `COUNT(settle_type IN (1,3))` 即结算单数(含商城订单);BD manual / dws_tasks.md 描述误导 | 待修订(随 #3 推到 Sprint 3) |
| 3 | F1-6 #3 累计交易笔数 | 按 Neo 业务语义"开台次数"(不含 settle_type=3 商城订单),需 ETL 在 DWS 新增 `total_open_table_count = COUNT(settle_type=1)` 字段 | 推迟 Sprint 3(ETL 配合) |
| 4 | DWD 不可被后端 / 应用层直读 | F1-6 Sprint 2 #3 调研发现 `app7_customer` AI prompt 当前直接读 `app.v_dwd_settlement_head` COUNT(*),违反"DWD 孤立"原则 | 待重构(随大架构演进) |
| 5 | 后端 fdw_queries.py 中所有 `app.v_dwd_*` 直读点 | 需要梳理全部,逐一改走 Core / DWS 统一接口 | 待清单化 |
| 6 | F1-7+ thin wrapper 收尾 sprint | F1-6 全部迁完后清理 fdw_queries 的 thin wrapper(详见 sandbox-replay-engine-spec.md §5.5) | 与本演进同步 |
## 四、不属于本 backlog
- **F1-6 沙箱时光机阶段 B**(Sprint 1-4)— 仍按现有 ETL 库结构推进,不等本演进。Sprint 推进过程中遇到本 backlog 第 2-5 项的具体问题,各自登记到对应 Sprint 任务清单。
- **架构设计细节**(Core 层 schema 定义、库迁移策略、连接器适配 SDK 等)— 本文件仅做需求登记,详细设计待优先级到位时另起 spec。
## 五、关联
- F1-6 Sprint 3 任务清单:[`docs/_overview/wave1-findings/F1-6-tasks.md`](wave1-findings/F1-6-tasks.md) §4
- 沙箱时光机模块 spec:[`docs/_overview/sandbox-replay-engine-spec.md`](sandbox-replay-engine-spec.md) §5.5(thin wrapper)+ §11(远期目标)
- DWD 强制规则:[`apps/etl/connectors/feiqiu/CLAUDE.md`](../../apps/etl/connectors/feiqiu/CLAUDE.md) §DWD 强制规则(12 条)
## 六、决策与变更记录
| 日期 | 决策 / 变更 | 触发 |
|------|------------|------|
| 2026-05-06 | 创建本 backlog | F1-6 Sprint 2 #3 累计交易笔数调研发现 DWS 字段名与实算口径矛盾 + app7 直读 DWD 违规,Neo 决定将 DWD 孤立 + Core 中间件目标提上任务表 |

View File

@@ -14,7 +14,7 @@
|--------|------|------|------| |--------|------|------|------|
| **Sprint 1** | 框架(sandbox_replay 模块 + runtime_aware decorator) + 1 个试点指标(距上次到店天数迁移) | M ~ 4-5h | ✅ 完成(2026-05-05) | | **Sprint 1** | 框架(sandbox_replay 模块 + runtime_aware decorator) + 1 个试点指标(距上次到店天数迁移) | M ~ 4-5h | ✅ 完成(2026-05-05) |
| **Sprint 2** | 5 个会员相关 P1 指标(60d 消费 / 累计消费总额 / 累计交易笔数 / 储值卡余额 / 累计 GMV) | M ~ 4h | 🔄 进行中(#1 60d 消费 ✅ 2026-05-06) | | **Sprint 2** | 5 个会员相关 P1 指标(60d 消费 / 累计消费总额 / 累计交易笔数 / 储值卡余额 / 累计 GMV) | M ~ 4h | 🔄 进行中(#1 60d 消费 ✅ 2026-05-06) |
| Sprint 3 | 5 个助教/门店相关 P1 + **MP-2 完整**(daily salary 含 ETL 改造) | L ~ 8-10h | ⏳ 待启动 | | Sprint 3 | 5 个助教/门店 P1 + **MP-2 完整**(daily salary 含 ETL 改造)+ Sprint 2 推迟的 #3(ETL 新增 `total_open_table_count`)| L ~ 9-11h | ⏳ 待启动 |
| Sprint 4 | 5 个 P2 指标(RS 重算 / 客户黏性 / 任务完成率 / Excel 修正 / 月度新增流失) | M-L ~ 6-8h | ⏳ 待启动 | | Sprint 4 | 5 个 P2 指标(RS 重算 / 客户黏性 / 任务完成率 / Excel 修正 / 月度新增流失) | M-L ~ 6-8h | ⏳ 待启动 |
## 二、Sprint 1 收口(2026-05-05) ## 二、Sprint 1 收口(2026-05-05)
@@ -79,8 +79,8 @@
|---|------|-------------|----------|------|------| |---|------|-------------|----------|------|------|
| 1 | 60 天消费 | `fdw_queries.get_consumption_60d` | `sandbox_replay/consumption_replay.py`(扩展) | S | ✅ 2026-05-06(thin wrapper)| | 1 | 60 天消费 | `fdw_queries.get_consumption_60d` | `sandbox_replay/consumption_replay.py`(扩展) | S | ✅ 2026-05-06(thin wrapper)|
| 2 | 累计消费总额 | (无,新增) | `sandbox_replay/consumption_replay.py`(扩展) | S | ✅ 2026-05-06(无 wrapper,0 调用方)| | 2 | 累计消费总额 | (无,新增) | `sandbox_replay/consumption_replay.py`(扩展) | S | ✅ 2026-05-06(无 wrapper,0 调用方)|
| 3 | 累计交易笔数 | (字段未定,需 Neo 决断 dws_order_summary vs total_visit_count) | `sandbox_replay/consumption_replay.py`(扩展) | S | ⏸️ **暂停**(spec §4 字段未明确)| | 3 | 累计交易笔数 | DWS 现有 `total_visit_count` 实算 `COUNT(settle_type IN (1,3))` 不符开台次数语义,需 ETL 新增 `total_open_table_count` | `sandbox_replay/consumption_replay.py`(扩展) | S | ⏸️ **推迟 Sprint 3**(ETL 配合,详见 [架构演进 backlog](../architecture-evolution-backlog.md))|
| 4 | 会员储值卡余额 | `fdw_queries.get_member_balance` | `sandbox_replay/balance_replay.py`(新建) | S | ⏳ 待启动 | | 4 | 会员储值卡余额 | `fdw_queries.get_member_balance` | `sandbox_replay/balance_replay.py`(新建) | S | ✅ 2026-05-06(thin wrapper,SCD2 时光机)|
| 5 | 累计 GMV | `dws_finance_daily_summary.gross_amount`(门店级,与现有"区间 GMV"语义不同) | `sandbox_replay/finance_replay.py`(新建) | S | ⏳ 待启动 | | 5 | 累计 GMV | `dws_finance_daily_summary.gross_amount`(门店级,与现有"区间 GMV"语义不同) | `sandbox_replay/finance_replay.py`(新建) | S | ⏳ 待启动 |
### Sprint 2 实施模式 ### Sprint 2 实施模式
@@ -92,8 +92,9 @@
### Sprint 2 commit ### Sprint 2 commit
- #1 60d 消费 — commit `d418621`(2026-05-06) - #1 60d 消费 — commit `d418621`(2026-05-06)
- #2 累计消费总额 — `feat(backend): F1-6 sprint2 #2 累计消费总额加入 sandbox_replay`(待提交) - #2 累计消费总额 — commit `32716bc`(2026-05-06)
- #3 累计交易笔数 — **暂停**(spec §4 字段未明确,需 Neo 决断 `dws_order_summary` vs `dws_member_consumption_summary.total_visit_count`) - #3 累计交易笔数 — **推迟 Sprint 3**(ETL 配合新增 `total_open_table_count`,详见 [架构演进 backlog](../architecture-evolution-backlog.md) 第 3 项)
- #4 储值卡余额 — `feat(backend): F1-6 sprint2 #4 储值卡余额迁移 sandbox_replay (SCD2 时光机)`(待提交)
### 估算 ### 估算
5 指标 × 30-50min = 3-4h(#1 实际 ~ 40min) 5 指标 × 30-50min = 3-4h(#1 实际 ~ 40min)

View File

@@ -1,12 +1,13 @@
# 审计一览表 # 审计一览表
> 自动生成于 2026-05-06 00:51:54,请勿手动编辑。 > 自动生成于 2026-05-06 01:25:49,请勿手动编辑。
## 时间线视图 ## 时间线视图
| 日期 | 项目 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 | | 日期 | 项目 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 |
|------|------|----------|----------|----------|------|------| |------|------|----------|----------|----------|------|------|
| 2026-05-06 | 项目级 | 2026-05-06 · F1-6 Sprint 2 #1 — 60 天消费迁移到 sandbox_replay | 清理 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_consumption_60d.md) | | 2026-05-06 | 项目级 | 2026-05-06 · F1-6 Sprint 2 #1 — 60 天消费迁移到 sandbox_replay | 清理 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_consumption_60d.md) |
| 2026-05-06 | 项目级 | 2026-05-06 · F1-6 Sprint 2 #4 — 储值卡余额迁移到 sandbox_replay(SCD2 时光机) | 功能 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_member_balance.md) |
| 2026-05-06 | 项目级 | 2026-05-06 · F1-6 Sprint 2 #2 — 累计消费总额加入 sandbox_replay | 清理 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_total_consume_amount.md) | | 2026-05-06 | 项目级 | 2026-05-06 · F1-6 Sprint 2 #2 — 累计消费总额加入 sandbox_replay | 清理 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_total_consume_amount.md) |
| 2026-05-05 | 项目级 | 2026-05-05 · F1-6 Sprint 1 沙箱时光机引擎启动 + get_last_visit_days 试点迁移 | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md) | | 2026-05-05 | 项目级 | 2026-05-05 · F1-6 Sprint 1 沙箱时光机引擎启动 + get_last_visit_days 试点迁移 | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md) |
| 2026-05-05 | 项目级 | 2026-05-05 — Wave 1 F1-5a 完整走查(应查尽查版) | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_backend_walkthrough.md) | | 2026-05-05 | 项目级 | 2026-05-05 — Wave 1 F1-5a 完整走查(应查尽查版) | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_backend_walkthrough.md) |
@@ -291,6 +292,7 @@
| 日期 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 | | 日期 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 |
|------|----------|----------|----------|------|------| |------|----------|----------|----------|------|------|
| 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #1 — 60 天消费迁移到 sandbox_replay | 清理 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_consumption_60d.md) | | 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #1 — 60 天消费迁移到 sandbox_replay | 清理 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_consumption_60d.md) |
| 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #4 — 储值卡余额迁移到 sandbox_replay(SCD2 时光机) | 功能 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_member_balance.md) |
| 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #2 — 累计消费总额加入 sandbox_replay | 清理 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_total_consume_amount.md) | | 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #2 — 累计消费总额加入 sandbox_replay | 清理 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_total_consume_amount.md) |
| 2026-05-05 | 2026-05-05 · F1-6 Sprint 1 沙箱时光机引擎启动 + get_last_visit_days 试点迁移 | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md) | | 2026-05-05 | 2026-05-05 · F1-6 Sprint 1 沙箱时光机引擎启动 + get_last_visit_days 试点迁移 | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md) |
| 2026-05-05 | 2026-05-05 — Wave 1 F1-5a 完整走查(应查尽查版) | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_backend_walkthrough.md) | | 2026-05-05 | 2026-05-05 — Wave 1 F1-5a 完整走查(应查尽查版) | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_backend_walkthrough.md) |
@@ -464,6 +466,7 @@
| 日期 | 需求摘要 | 变更类型 | 风险 | 详情 | | 日期 | 需求摘要 | 变更类型 | 风险 | 详情 |
|------|----------|----------|------|------| |------|----------|----------|------|------|
| 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #1 — 60 天消费迁移到 sandbox_replay | 清理 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_consumption_60d.md) | | 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #1 — 60 天消费迁移到 sandbox_replay | 清理 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_consumption_60d.md) |
| 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #4 — 储值卡余额迁移到 sandbox_replay(SCD2 时光机) | 功能 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_member_balance.md) |
| 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #2 — 累计消费总额加入 sandbox_replay | 清理 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_total_consume_amount.md) | | 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #2 — 累计消费总额加入 sandbox_replay | 清理 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_total_consume_amount.md) |
| 2026-05-05 | 2026-05-05 · F1-6 Sprint 1 沙箱时光机引擎启动 + get_last_visit_days 试点迁移 | bugfix | 未知 | [链接](changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md) | | 2026-05-05 | 2026-05-05 · F1-6 Sprint 1 沙箱时光机引擎启动 + get_last_visit_days 试点迁移 | bugfix | 未知 | [链接](changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md) |
| 2026-05-05 | 2026-05-05 — Wave 1 F1-5a 完整走查(应查尽查版) | bugfix | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_backend_walkthrough.md) | | 2026-05-05 | 2026-05-05 — Wave 1 F1-5a 完整走查(应查尽查版) | bugfix | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_backend_walkthrough.md) |

View File

@@ -0,0 +1,121 @@
# 2026-05-06 · F1-6 Sprint 2 #4 — 储值卡余额迁移到 sandbox_replay(SCD2 时光机)
> F1-6 沙箱时光机阶段 B Sprint 2 #4(详见 `docs/_overview/wave1-findings/F1-6-tasks.md` §3)
>
> 工作量评估 S / 30-50min(实际 ~ 40min)
>
> Sprint 2 进度:#1 ✅ → #2 ✅ → #3 推迟 Sprint 3 → **#4 储值卡余额 ☚** → #5 累计 GMV
>
> 完整模块 spec:`docs/_overview/sandbox-replay-engine-spec.md` §5.5(thin wrapper 决策)
## 背景
#4 储值卡余额是 Sprint 2 第 4 个迁移指标。**与 #1 #2 不同,数据源是 SCD2 维度表 `dim_member_card_account`,非 daily 累计快照表**。SCD2 维度表原生支持时光机回溯,sandbox 改造关键在替换 `scd2_is_current=1` 过滤为 `scd2_start_time` + `scd2_end_time` 时间过滤。
## 改动清单
### Step 3 实施
**新增文件**(1 个):
- [apps/backend/app/services/sandbox_replay/balance_replay.py](apps/backend/app/services/sandbox_replay/balance_replay.py) — 新建模块,实现 `get_member_balance` 函数
**修改文件**(2 个):
- [apps/backend/app/services/sandbox_replay/__init__.py](apps/backend/app/services/sandbox_replay/__init__.py) — re-export `get_member_balance_replay`
- [apps/backend/app/services/fdw_queries.py](apps/backend/app/services/fdw_queries.py)`get_member_balance`(行 ~185)改 thin wrapper,委托给 sandbox_replay 实现,保持 5 处现有调用兼容
**测试文件**(本地不入仓 `.gitignore:71`):
- [apps/backend/tests/test_sandbox_replay_sprint2.py](apps/backend/tests/test_sandbox_replay_sprint2.py) — 累计 14 case(本指标 5 case)
测试覆盖:
- 空 member_ids 返回空 dict
- 正常返回 {member_id: Decimal} 映射
- sandbox 模式 SQL 用 SCD2 时光机过滤(`scd2_start_time` + `scd2_end_time`),不再含 `scd2_is_current`,SUM(balance) 多卡聚合保留
- SUM(balance) 为 NULL 时返回 Decimal('0')(沿用原行为)
- thin wrapper 委托链路验证
合并 sprint 1 + sprint 2 #1/#2/#4 测试套件:**24/24 PASS,无回归**。
### SCD2 时光机 SQL 关键差异
```sql
-- 原 fdw_queries(live only):
WHERE tenant_member_id = ANY(%s)
AND scd2_is_current = 1
GROUP BY tenant_member_id
-- 新 sandbox_replay(时光机):
WHERE tenant_member_id = ANY(%s)
AND scd2_start_time < (%s::date + INTERVAL '1 day')
AND (scd2_end_time IS NULL
OR scd2_end_time >= (%s::date + INTERVAL '1 day'))
GROUP BY tenant_member_id
```
`ref_date = ctx.business_date`,`ref_date+1day` 边界 = `ref_date` 当天结束时仍 active 的版本。
live 模式 ref_date=today,效果等同 `scd2_is_current=1`(实测验证)。
### Step 4 双口径 UI 走查证据
**目标 member**: 2799207363643141(葛先生,138****8071)
> ⚠️ 切换 member 原因:黄先生(2799207087163141)在测试库 SCD2 历史中 `dim_member_card_account` 自 2026-04-06 起无变更,sandbox=2026-04-20 与 live 都返回 ¥547.29(无差异),不能演示时光机效果。葛先生在 04-20 与 05-05 之间有大额余额变化,差异 1.1w+,演示效果最显著。
**双截图对照**:
| 卡条位置 | 4a live (today=2026-05-05) | 4b sandbox=2026-04-20 |
|---------|---------------------------|----------------------|
| **#1 储值余额 ☚** | **¥6,602** | **¥18,080** |
| #2 60天消费 | ¥73,614 | --(测试库 dws 无 stat_date<=04-20 数据)|
| #3 理想间隔 | 1天 | 1天 |
| #4 距今到店 | 11天 | --(同上 dws 数据局限)|
走查截图(双口径 #1 格 sandbox 时光机数值变化清晰可见):
- `_DEL/walkthrough_f1_6/sprint2_4a_live_balance.png` — 顶部 stat 卡条 `¥6,602 / ¥73,614 / 1天 / 11天`
- `_DEL/walkthrough_f1_6/sprint2_4b_sandbox_balance.png` — 顶部 stat 卡条 `¥18,080 / -- / 1天 / --`
**关键证据**:同一会员葛先生,SCD2 维度表里 2026-04-20 时余额 ¥18,079.55,2026-05-05(live)余额 ¥6,602.07,sandbox 切换准确还原历史余额。**差异 1.1w+,SCD2 时光机过滤生效铁证**。
### Step 5 审计
本文件 + F1-6-tasks.md Sprint 2 进度推进 + audit dashboard 刷新。
## 影响范围
| 端 | 影响 | 验证 |
|----|------|------|
| 后端 sandbox_replay.balance_replay | **新增** 模块 + `get_member_balance` 函数 | unit test 5/5 + 累计 24/24 PASS |
| 后端 fdw_queries.get_member_balance | 改 thin wrapper(行为完全一致) | UI 4a/4b 双口径 PASS |
| 后端 5 处调用方(chat / coach / customer ×2 / task_manager)| 无感(thin wrapper 透明委托,签名不变)| 4a=¥6,602 / 4b=¥18,080 |
| 小程序 customer-detail | 无感(API 字段名不变)| UI 双截图实地确认 |
| admin-web / 租户后台 / ETL | 无影响 | — |
## 测试
- 后端 unit test 24/24 PASS(本地 `apps/backend/tests/test_sandbox_replay_sprint{1,2}.py`,因 `.gitignore:71` 不入仓)
- MCP 端到端 4a/4b 双口径 PASS(含 navigate_to + snapshot + screenshot **完整 UI 验证**)
- 直接 SQL 实测 SCD2 时光机过滤行为验证
- 时区敏感性验证(Asia/Shanghai 一致 + PG date+1d vs timestamptz 比较语义稳定)
## 风险与未覆盖
- **现有口径未拆分储值卡 vs 赠送卡**:`get_member_balance` 直接 SUM 全部 balance,本 sprint 保持口径不变。如未来需展示"储值卡余额"+"赠送卡余额"分项(参考 dws_finance_recharge_summary.cash_card_balance / gift_card_balance 字段),登记到架构演进 backlog
- **`scd2_end_time` 当前 ETL 默认值是 `'9999-12-31'` 而非 NULL**:SQL 用 `IS NULL OR >= ref_date+1` 兼容两种约定,防御未来 ETL 改动
- **测试库 SCD2 历史数据局限**:并非所有会员都有 sandbox 期内的余额变化历史,需选择有差异的样本验证(本次选葛先生)
- **多连接器架构演进**:本 SCD2 时光机查询直接读 dwd 维度表(通过 app.v_dim_member_card_account 视图),长远应通过 Core 层统一接口(详见 [架构演进 backlog](../../_overview/architecture-evolution-backlog.md) 第 4 项)
## 回滚策略
```bash
git revert <commit_hash>
```
回滚后:
- 删除 `sandbox_replay/balance_replay.py` 新建文件
- `sandbox_replay/__init__.py` 移除 re-export
- `fdw_queries.get_member_balance` 恢复原 `scd2_is_current=1` 实现
- 5 处调用点无影响(thin wrapper 透明)
- 测试文件本地保留(`.gitignore` 范围内)
## Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>