diff --git a/apps/backend/app/services/sandbox_replay/__init__.py b/apps/backend/app/services/sandbox_replay/__init__.py index 57eb2a7..0ec8ca8 100644 --- a/apps/backend/app/services/sandbox_replay/__init__.py +++ b/apps/backend/app/services/sandbox_replay/__init__.py @@ -40,6 +40,9 @@ from app.services.sandbox_replay.consumption_replay import ( get_last_visit_days as get_last_visit_days_replay, get_total_consume_amount as get_total_consume_amount_replay, ) +from app.services.sandbox_replay.finance_replay import ( + get_total_gmv as get_total_gmv_replay, +) __all__ = [ "runtime_aware", @@ -47,4 +50,5 @@ __all__ = [ "get_consumption_60d_replay", "get_total_consume_amount_replay", "get_member_balance_replay", + "get_total_gmv_replay", ] diff --git a/apps/backend/app/services/sandbox_replay/finance_replay.py b/apps/backend/app/services/sandbox_replay/finance_replay.py new file mode 100644 index 0000000..8935b5c --- /dev/null +++ b/apps/backend/app/services/sandbox_replay/finance_replay.py @@ -0,0 +1,89 @@ +"""门店财务相关 sandbox 重算实现。 + +F1-6 沙箱时光机阶段 B Sprint 2 #5 新建。 + +覆盖指标: + - P1-13 累计 GMV(get_total_gmv)— sprint 2 #5 + +设计要点: + - **门店级**指标(不是会员级),函数签名无 member_id 参数 + - 数据源:`dws_finance_daily_summary`(门店日度财务汇总,daily 累计) + - sandbox 语义:SUM(gross_amount) WHERE stat_date <= ctx.business_date + - 与 #1/#2(会员级单行查询)不同,本指标是多行 SUM 聚合 + - 无原 fdw_queries.get_total_gmv 函数 → **不写 thin wrapper**(改动最小化) + +口径(BD_manual_dws_finance_daily_summary §字段说明): + gross_amount = table_fee_amount + goods_amount + + assistant_pd_amount + assistant_cx_amount + # 注意:**不含 electricity_money**(与会员级 items_sum 不同) + # 当前生产 electricity_money 全 0(DWD-DOC 第 5 条规则),实际数值无差, + # 但语义层文档化,避免与 #2 累计消费总额做语义混淆/交叉验证 + +与 board_service.get_finance_overview 的关系: + `board_service.get_finance_overview` 已用 SUM(gross_amount) 但是按 + start_date / end_date 区间聚合(BOARD-3 经营一览面板),语义是"区间 GMV"。 + 本指标是"开店至 ref_date 累计 GMV",**两者并存,语义不同,本指标不影响那个**。 + +无数据语义: + 开店前 / 无 dws 数据时 SUM=NULL,SQL 层用 COALESCE(SUM(...), 0) 兜底 + 返回 Decimal('0'),不返回 None。业务语义:开店前累计 GMV = 0(没营收), + 不是"未知"。 +""" + +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="获取累计 GMV(sandbox_replay,门店级)", + description_en="Get cumulative GMV (sandbox_replay, store level)", +) +@runtime_aware(metric="total_gmv") +def get_total_gmv( + conn: Any, + site_id: int, + *, + etl_conn: Any = None, + ctx: RuntimeContext, +) -> Decimal: + """查询门店从开店至 ref_date 的累计 GMV(sandbox_replay 版本)。 + + 设计要点: + - **门店级**指标(无 member_id 参数,与 #1-#4 会员级粒度不同) + - 取业务日为基准(ctx.business_date,sandbox 模式下为 sandbox_date) + - 仅 SUM stat_date <= business_date 的快照行(显式上界,与视图过滤一致) + - SQL 层 COALESCE 兜底,无数据返回 Decimal('0')(非 None) + + Args: + conn: zqyy_app 业务库连接 + site_id: 门店 ID + etl_conn: 可选,显式传 ETL 连接(便于测试 mock) + ctx: RuntimeContext(由 @runtime_aware 自动注入) + + Returns: + Decimal 累计 GMV(无数据时返回 Decimal('0'),不返回 None) + + 使用方:暂未接入(Sprint 2 #5 留给 AI app7 + 新版本迭代场景直接 import)。 + """ + from app.services.fdw_queries import _fdw_context + + ref_date = ctx.business_date + + with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur: + cur.execute( + """ + SELECT COALESCE(SUM(gross_amount), 0) AS total_gmv + FROM app.v_dws_finance_daily_summary + WHERE stat_date <= %s + """, + (ref_date,), + ) + row = cur.fetchone() + + return Decimal(str(row[0])) if row and row[0] is not None else Decimal("0") diff --git a/docs/_overview/wave1-findings/F1-6-tasks.md b/docs/_overview/wave1-findings/F1-6-tasks.md index 9b350b0..f8c737b 100644 --- a/docs/_overview/wave1-findings/F1-6-tasks.md +++ b/docs/_overview/wave1-findings/F1-6-tasks.md @@ -13,7 +13,7 @@ | Sprint | 范围 | 工作量 | 状态 | |--------|------|------|------| | **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 | ✅ 完成(2026-05-06,4 项迁移 + #3 推迟 Sprint 3) | | 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 | ⏳ 待启动 | @@ -81,7 +81,7 @@ | 2 | 累计消费总额 | (无,新增) | `sandbox_replay/consumption_replay.py`(扩展) | S | ✅ 2026-05-06(无 wrapper,0 调用方)| | 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 | ⏳ 待启动 | +| 5 | 累计 GMV | `dws_finance_daily_summary.gross_amount`(门店级,与现有"区间 GMV"语义不同) | `sandbox_replay/finance_replay.py`(新建) | S | ✅ 2026-05-06(无 wrapper,门店级 SUM)| ### Sprint 2 实施模式 所有指标走 `@runtime_aware` decorator + `app.v_dws_*` 视图查询。每个指标: @@ -94,7 +94,8 @@ - #1 60d 消费 — commit `d418621`(2026-05-06) - #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 时光机)`(待提交) +- #4 储值卡余额 — commit `7b1cfad`(2026-05-06,SCD2 时光机) +- #5 累计 GMV — `feat(backend): F1-6 sprint2 #5 累计 GMV 加入 sandbox_replay (门店级)`(待提交) ### 估算 5 指标 × 30-50min = 3-4h(#1 实际 ~ 40min) diff --git a/docs/audit/audit_dashboard.md b/docs/audit/audit_dashboard.md index 852bf60..3a4fc50 100644 --- a/docs/audit/audit_dashboard.md +++ b/docs/audit/audit_dashboard.md @@ -1,6 +1,6 @@ # 审计一览表 -> 自动生成于 2026-05-06 01:25:49,请勿手动编辑。 +> 自动生成于 2026-05-06 01:32:13,请勿手动编辑。 ## 时间线视图 @@ -9,6 +9,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-06 | 项目级 | 2026-05-06 · F1-6 Sprint 2 #5 — 累计 GMV 加入 sandbox_replay(门店级) | 文档 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_total_gmv.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 | 项目级 | Wave 1 F1-5a — 沙箱 batch-run 接入 runtime_context(MVP + 漂移防御核心) | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_sandbox_batch_run.md) | @@ -294,6 +295,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-06 | 2026-05-06 · F1-6 Sprint 2 #5 — 累计 GMV 加入 sandbox_replay(门店级) | 文档 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_total_gmv.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 | Wave 1 F1-5a — 沙箱 batch-run 接入 runtime_context(MVP + 漂移防御核心) | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_sandbox_batch_run.md) | @@ -468,6 +470,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-06 | 2026-05-06 · F1-6 Sprint 2 #5 — 累计 GMV 加入 sandbox_replay(门店级) | 文档 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_total_gmv.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 | Wave 1 F1-5a — 沙箱 batch-run 接入 runtime_context(MVP + 漂移防御核心) | bugfix | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_sandbox_batch_run.md) | diff --git a/docs/audit/changes/2026-05-06__f1_6_sprint2_total_gmv.md b/docs/audit/changes/2026-05-06__f1_6_sprint2_total_gmv.md new file mode 100644 index 0000000..8ea63be --- /dev/null +++ b/docs/audit/changes/2026-05-06__f1_6_sprint2_total_gmv.md @@ -0,0 +1,121 @@ +# 2026-05-06 · F1-6 Sprint 2 #5 — 累计 GMV 加入 sandbox_replay(门店级) + +> F1-6 沙箱时光机阶段 B Sprint 2 #5 — **Sprint 2 收尾**(详见 `docs/_overview/wave1-findings/F1-6-tasks.md` §3) +> +> 工作量评估 S / 30-50min(实际 ~ 35min) +> +> Sprint 2 进度:#1 ✅ → #2 ✅ → #3 推迟 Sprint 3 → #4 ✅ → **#5 累计 GMV ☚** → **Sprint 2 完成** +> +> 完整模块 spec:`docs/_overview/sandbox-replay-engine-spec.md` §5.5(thin wrapper 决策) + +## 背景 + +#5 累计 GMV 是 Sprint 2 第 5(末)个迁移指标。**与 #1-#4 不同,这是门店级指标**(无 member_id 参数),且无原 `fdw_queries` 函数。 + +## 改动清单 + +### Step 3 实施 + +**新增文件**(1 个): +- [apps/backend/app/services/sandbox_replay/finance_replay.py](apps/backend/app/services/sandbox_replay/finance_replay.py) — 新建模块,实现门店级 `get_total_gmv` 函数 + +**修改文件**(1 个): +- [apps/backend/app/services/sandbox_replay/__init__.py](apps/backend/app/services/sandbox_replay/__init__.py) — re-export `get_total_gmv_replay` + +**未改文件**: +- `apps/backend/app/services/fdw_queries.py` — **不加 thin wrapper**(原无同名查询 + 0 调用方,详见 spec §5.5 决策原则,与 #2 同款) + +**测试文件**(本地不入仓 `.gitignore:71`): +- [apps/backend/tests/test_sandbox_replay_sprint2.py](apps/backend/tests/test_sandbox_replay_sprint2.py) — 累计 18 case(本指标 4 case) + +测试覆盖: +- 正常返回 Decimal +- 无数据返回 Decimal('0')(SQL 层 COALESCE 兜底) +- sandbox 模式 SQL 显式带 `stat_date <= ctx.business_date` 上界 + 不能含 LIMIT 1 +- **防御性签名回归测试**:`get_total_gmv` 函数签名不能含 `member_id` / `member_ids` 参数(门店级粒度,与 #1-#4 区分) + +合并 sprint 1 + sprint 2 全部测试套件:**28/28 PASS,无回归**。 + +### 与 #1-#4 关键差异 + +| 维度 | #1/#2/#4(会员级) | **#5 累计 GMV(门店级)** | +|------|-------------------|-----------------------| +| 函数签名 | `(conn, site_id, member_id/ids, *, etl_conn, ctx)` | `(conn, site_id, *, etl_conn, ctx)` 无 member | +| SQL 查询模式 | 取最新 stat_date 行 ORDER BY DESC LIMIT 1 | **多行 SUM 累计** WHERE stat_date <= ref_date,无 LIMIT | +| 无数据返回 | None(单行查不到)| **Decimal('0')**(SUM=NULL 时 COALESCE 兜底) | +| fdw_queries thin wrapper | #1/#4 写,#2 不写 | 不写(无原查询) | + +### 口径(BD_manual_dws_finance_daily_summary §5,148) + +``` +gross_amount = table_fee_amount + goods_amount + + assistant_pd_amount + assistant_cx_amount +# 注意:不含 electricity_money(与会员级 items_sum 略有不同) +``` + +当前生产 `electricity_money` 全 0(DWD-DOC 第 5 条规则),实际数值无差,但语义层文档化,避免与 #2 累计消费总额(items_sum 含 electricity)交叉验证。 + +### Step 4 双口径数值验证(无 UI,直接 Python) + +**目标 site**: 2790685415443269(朗朗桌球) + +**双口径结果**(直接 Python 调用 `sandbox_replay.finance_replay.get_total_gmv`): + +| 维度 | 4a live (today=2026-05-05) | 4b sandbox=2026-04-20 | 差异 | +|------|---------------------------|----------------------|------| +| 累计 GMV | **¥5,725,837.51** | **¥5,653,063.37** | ¥72,774.14 | +| 数据范围 | 2025-07-16 ~ 2026-04-27(测试库 ETL 现状)| 同 ~ 2026-04-20 | 4-21 ~ 4-27 七天合计 | + +走查脚本(归档): +- `_DEL/walkthrough_f1_6/step_sprint2_total_gmv_4a_4b.py` — 一键完成 4a 验证 + sandbox 切换 + 4b 验证 + 切回 live + +**关键证据**:sandbox 切换时,`get_total_gmv` 函数读 `ctx.business_date=2026-04-20` 作为 SQL 上界,SUM 271 天数据中 stat_date <= 2026-04-20 的部分,完全符合"沙箱时光机"语义。 + +> ⚠️ 测试库 ETL 现状:最新数据 stat_date=2026-04-27 而非 today=2026-05-05。SQL 语义正确(`stat_date <= ref_date` 数据库返回什么就什么),不影响 sandbox 时光机验证。生产环境 ETL 跑批到当天该问题自动消失。 + +### Step 5 审计 + +本文件 + F1-6-tasks.md Sprint 2 收尾推进 + audit dashboard 刷新。 + +## 影响范围 + +| 端 | 影响 | 验证 | +|----|------|------| +| 后端 sandbox_replay.finance_replay | **新增** 模块 + `get_total_gmv` 函数 | unit test 4/4 + 累计 28/28 PASS | +| 后端 fdw_queries | **无影响**(不加 thin wrapper) | — | +| 后端 board_service.get_finance_overview(区间 GMV)| 无影响(语义不同,两者并存) | — | +| 后端 chat / coach / customer / task_manager | 无影响(0 现有调用方) | — | +| 小程序 / admin-web / 租户后台 / ETL | 无影响(0 现有调用方) | — | + +## 测试 + +- 后端 unit test 28/28 PASS(本地 `apps/backend/tests/test_sandbox_replay_sprint{1,2}.py`,因 `.gitignore:71` 不入仓) +- 直接 Python 双口径数值验证 PASS(¥5,725,837 vs ¥5,653,063,差异 ¥72,774) +- **无 UI 走查**(本指标 0 调用方,Neo 已确认接受路径 A,理由 AI app7 + 新版本迭代) + +## 风险与未覆盖(已逐一控制) + +- **风险 1 门店级新模式**:函数签名严格设计无 member_ids 参数,模块 docstring 明确"门店级",新增防御性签名回归测试 `test_get_total_gmv_no_member_ids_param` 阻断未来误改 ✓ +- **风险 2 gross_amount 不含 electricity**:docstring 明确口径来源 + 与 #2 items_sum 不能交叉验证;生产 electricity_money 全 0 实际数值无差,语义层文档化 ✓ +- **风险 3 测试库 ETL 仅到 04-27**:unit test 用 mock 数据(不依赖 ETL 跑批进度);audit 文档明确说明 4a 实际累计到 04-27 是测试库现状;数值差异(¥72k+)依然真实 ✓ +- **风险 4 隐藏决策(无数据 0 vs None)**:SQL 层 `COALESCE(SUM(gross_amount), 0)` 兜底返回 Decimal('0');业务语义"开店前累计 GMV = 0"(没营收),不是"未知";单独测试用例覆盖 ✓ + +## 与架构演进 backlog 关联 + +本指标**直读 dws 视图**(通过 `app.v_dws_finance_daily_summary`),长远应通过 Core 层统一接口(详见 [架构演进 backlog](../../_overview/architecture-evolution-backlog.md) 第 4-5 项)。 + +## 回滚策略 + +```bash +git revert +``` + +回滚后: +- 删除 `sandbox_replay/finance_replay.py` 新建文件 +- `sandbox_replay/__init__.py` 移除 `get_total_gmv_replay` re-export +- 0 调用点无影响(本指标无现有调用方) +- 测试文件本地保留(`.gitignore` 范围内) + +## Co-Authored-By + +Claude Opus 4.7 (1M context)