From 7b1cfadc2ea5867c40e419dd329cf3ffadc964bd Mon Sep 17 00:00:00 2001 From: Neo Date: Wed, 6 May 2026 01:26:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20F1-6=20sprint2=20#4=20?= =?UTF-8?q?=E5=82=A8=E5=80=BC=E5=8D=A1=E4=BD=99=E9=A2=9D=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=20sandbox=5Freplay=20(SCD2=20=E6=97=B6=E5=85=89=E6=9C=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建 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) --- apps/backend/app/services/fdw_queries.py | 35 ++--- .../app/services/sandbox_replay/__init__.py | 4 + .../services/sandbox_replay/balance_replay.py | 106 +++++++++++++++ .../architecture-evolution-backlog.md | 49 +++++++ docs/_overview/wave1-findings/F1-6-tasks.md | 11 +- docs/audit/audit_dashboard.md | 5 +- ...2026-05-06__f1_6_sprint2_member_balance.md | 121 ++++++++++++++++++ 7 files changed, 299 insertions(+), 32 deletions(-) create mode 100644 apps/backend/app/services/sandbox_replay/balance_replay.py create mode 100644 docs/_overview/architecture-evolution-backlog.md create mode 100644 docs/audit/changes/2026-05-06__f1_6_sprint2_member_balance.md diff --git a/apps/backend/app/services/fdw_queries.py b/apps/backend/app/services/fdw_queries.py index cd3bc56..7e9c882 100644 --- a/apps/backend/app/services/fdw_queries.py +++ b/apps/backend/app/services/fdw_queries.py @@ -182,38 +182,21 @@ def get_member_info( return result -@trace_service(description_zh="获取会员余额", description_en="Get member balance") def get_member_balance( conn: Any, site_id: int, member_ids: list[int], *, etl_conn: Any = None, ) -> 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, - 取 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 + return _replay_impl(conn, site_id, member_ids, etl_conn=etl_conn) def get_last_visit_days( diff --git a/apps/backend/app/services/sandbox_replay/__init__.py b/apps/backend/app/services/sandbox_replay/__init__.py index abcb2e5..57eb2a7 100644 --- a/apps/backend/app/services/sandbox_replay/__init__.py +++ b/apps/backend/app/services/sandbox_replay/__init__.py @@ -32,6 +32,9 @@ F1-6 沙箱时光机阶段 B 启动模块。 """ 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 ( get_consumption_60d as get_consumption_60d_replay, get_last_visit_days as get_last_visit_days_replay, @@ -43,4 +46,5 @@ __all__ = [ "get_last_visit_days_replay", "get_consumption_60d_replay", "get_total_consume_amount_replay", + "get_member_balance_replay", ] diff --git a/apps/backend/app/services/sandbox_replay/balance_replay.py b/apps/backend/app/services/sandbox_replay/balance_replay.py new file mode 100644 index 0000000..90709b9 --- /dev/null +++ b/apps/backend/app/services/sandbox_replay/balance_replay.py @@ -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 diff --git a/docs/_overview/architecture-evolution-backlog.md b/docs/_overview/architecture-evolution-backlog.md new file mode 100644 index 0000000..bc7eea5 --- /dev/null +++ b/docs/_overview/architecture-evolution-backlog.md @@ -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 中间件目标提上任务表 | diff --git a/docs/_overview/wave1-findings/F1-6-tasks.md b/docs/_overview/wave1-findings/F1-6-tasks.md index 9748ea6..9b350b0 100644 --- a/docs/_overview/wave1-findings/F1-6-tasks.md +++ b/docs/_overview/wave1-findings/F1-6-tasks.md @@ -14,7 +14,7 @@ |--------|------|------|------| | **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 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 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)| | 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 字段未明确)| -| 4 | 会员储值卡余额 | `fdw_queries.get_member_balance` | `sandbox_replay/balance_replay.py`(新建) | S | ⏳ 待启动 | +| 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 | ✅ 2026-05-06(thin wrapper,SCD2 时光机)| | 5 | 累计 GMV | `dws_finance_daily_summary.gross_amount`(门店级,与现有"区间 GMV"语义不同) | `sandbox_replay/finance_replay.py`(新建) | S | ⏳ 待启动 | ### Sprint 2 实施模式 @@ -92,8 +92,9 @@ ### Sprint 2 commit - #1 60d 消费 — commit `d418621`(2026-05-06) -- #2 累计消费总额 — `feat(backend): F1-6 sprint2 #2 累计消费总额加入 sandbox_replay`(待提交) -- #3 累计交易笔数 — **暂停**(spec §4 字段未明确,需 Neo 决断 `dws_order_summary` vs `dws_member_consumption_summary.total_visit_count`) +- #2 累计消费总额 — commit `32716bc`(2026-05-06) +- #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) diff --git a/docs/audit/audit_dashboard.md b/docs/audit/audit_dashboard.md index 727cc57..852bf60 100644 --- a/docs/audit/audit_dashboard.md +++ b/docs/audit/audit_dashboard.md @@ -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 #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-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) | @@ -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 #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-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) | @@ -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 #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-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) | diff --git a/docs/audit/changes/2026-05-06__f1_6_sprint2_member_balance.md b/docs/audit/changes/2026-05-06__f1_6_sprint2_member_balance.md new file mode 100644 index 0000000..9b6fff6 --- /dev/null +++ b/docs/audit/changes/2026-05-06__f1_6_sprint2_member_balance.md @@ -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 +``` + +回滚后: +- 删除 `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)