From 32716bc71a1791386492339cc9a3c2669f14c1b6 Mon Sep 17 00:00:00 2001 From: Neo Date: Wed, 6 May 2026 00:52:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20F1-6=20sprint2=20#2=20?= =?UTF-8?q?=E7=B4=AF=E8=AE=A1=E6=B6=88=E8=B4=B9=E6=80=BB=E9=A2=9D=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=20sandbox=5Freplay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增指标(无 fdw_queries 原查询 + 0 现有调用方 + 无 thin wrapper),沿用 sprint 1/sprint2 #1 模式 @trace_service + @runtime_aware decorator + 显式 stat_date <= ctx.business_date 上界 + dws_member_consumption_summary .total_consume_amount 字段 items_sum 口径。 双口径数值验证 PASS(member=2799207087163141 黄先生,直接 Python 调用): - 4a live(today=2026-05-05): get_total_consume_amount=1252.65 - 4b sandbox=2026-04-20: get_total_consume_amount=999.99(walkthrough 测试快照) unit test sprint1+sprint2 累计 19/19 PASS,无回归。 记录 thin wrapper 决策原则到 spec §5.5(迁移辅助层,非常态架构; fdw_queries 长远退化纯 ETL 物理访问层,清理放收尾 sprint)。 注:#3 累计交易笔数因 spec §4 字段未明确(dws_order_summary vs total_visit_count)暂停,等 Neo 决断后继续。 详见 docs/audit/changes/2026-05-06__f1_6_sprint2_total_consume_amount.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/services/sandbox_replay/__init__.py | 2 + .../sandbox_replay/consumption_replay.py | 65 +++++++++++ docs/_overview/sandbox-replay-engine-spec.md | 29 +++++ docs/_overview/wave1-findings/F1-6-tasks.md | 12 +- docs/audit/audit_dashboard.md | 5 +- ...5-06__f1_6_sprint2_total_consume_amount.md | 104 ++++++++++++++++++ 6 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 docs/audit/changes/2026-05-06__f1_6_sprint2_total_consume_amount.md diff --git a/apps/backend/app/services/sandbox_replay/__init__.py b/apps/backend/app/services/sandbox_replay/__init__.py index f3c03fc..abcb2e5 100644 --- a/apps/backend/app/services/sandbox_replay/__init__.py +++ b/apps/backend/app/services/sandbox_replay/__init__.py @@ -35,10 +35,12 @@ from app.services.sandbox_replay._decorator import runtime_aware 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, + get_total_consume_amount as get_total_consume_amount_replay, ) __all__ = [ "runtime_aware", "get_last_visit_days_replay", "get_consumption_60d_replay", + "get_total_consume_amount_replay", ] diff --git a/apps/backend/app/services/sandbox_replay/consumption_replay.py b/apps/backend/app/services/sandbox_replay/consumption_replay.py index 6096df0..9c99b28 100644 --- a/apps/backend/app/services/sandbox_replay/consumption_replay.py +++ b/apps/backend/app/services/sandbox_replay/consumption_replay.py @@ -155,3 +155,68 @@ def get_consumption_60d( row = cur.fetchone() return Decimal(str(row[0])) if row and row[0] is not None else None + + +# ── P1-3 累计消费总额 ──────────────────────────────────── + + +@trace_service( + description_zh="获取累计消费总额(sandbox_replay)", + description_en="Get total consume amount (sandbox_replay)", +) +@runtime_aware(metric="total_consume_amount") +def get_total_consume_amount( + conn: Any, + site_id: int, + member_id: int, + *, + etl_conn: Any = None, + ctx: RuntimeContext, +) -> Decimal | None: + """查询会员从注册至 ref_date 的累计消费总额(sandbox_replay 版本)。 + + 设计要点: + - **新增指标**:fdw_queries 无原查询,sandbox_replay 直接落地 + - 取业务日为基准(ctx.business_date,sandbox 模式下为 sandbox_date) + - 仅查 stat_date <= business_date 的快照行(显式上界,与视图过滤一致) + - 取最新 stat_date 行的 total_consume_amount + + 口径(BD_manual_dws_member_consumption_summary §金额口径): + - items_sum = table_charge_money + goods_money + assistant_pd_money + + assistant_cx_money + electricity_money + - total_consume_amount 是 ETL 跑批时按 items_sum 累计的全期消费 + - sandbox 切到历史日期时,视图按 stat_date <= business_date_now() 过滤, + 取当时 ETL 写入的累计快照(符合"沙箱时光机"语义) + + Args: + conn: zqyy_app 业务库连接 + site_id: 门店 ID + member_id: 单个会员 ID + etl_conn: 可选,显式传 ETL 连接(便于测试 mock) + ctx: RuntimeContext(由 @runtime_aware 自动注入) + + Returns: + Decimal 累计金额或 None(无快照 / 字段为 NULL 时) + + 使用方:暂未接入(Sprint 2 #2 留给 AI app7 客户分析 prompt 拼接 + + 新版本迭代直接 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 total_consume_amount + FROM app.v_dws_member_consumption_summary + WHERE member_id = %s + AND stat_date <= %s + ORDER BY stat_date DESC + LIMIT 1 + """, + (member_id, ref_date), + ) + row = cur.fetchone() + + return Decimal(str(row[0])) if row and row[0] is not None else None diff --git a/docs/_overview/sandbox-replay-engine-spec.md b/docs/_overview/sandbox-replay-engine-spec.md index f3aa0b3..f7e6757 100644 --- a/docs/_overview/sandbox-replay-engine-spec.md +++ b/docs/_overview/sandbox-replay-engine-spec.md @@ -119,6 +119,35 @@ def get_member_balance(site_id: int, member_id: int) -> Decimal: - 建议:sandbox 模式下接受性能折衷;live 模式仍走原 dws 月度路径 - 必要时可做 in-memory cache(business_date 不变时缓存命中) +### 5.5 thin wrapper 使用原则(2026-05-06 决策) + +`fdw_queries.*` 中的 thin wrapper(转发到 `sandbox_replay.*`)是**迁移辅助层,不是常态架构**。决策树: + +``` +Q1: fdw_queries 已有同名函数? + └─ 没有 → 不写 thin wrapper(直接在 sandbox_replay 加新函数) + └─ 有 → Q2 + +Q2: 业务方已经在调用 fdw_queries.X? + └─ 没有 → 不写 thin wrapper(老函数直接删) + └─ 有 → Q3 + +Q3: 改这些调用方的 import 风险大? + └─ 改起来便宜(≤3 处) → 直接改 import,不写 thin wrapper + └─ 改起来贵(多处 / 容易漏改) → 写 thin wrapper +``` + +**对 Sprint 2 应用**: +- #1 60d 消费(原有 + 2 处调用)→ 写 thin wrapper(已完成) +- #2 累计消费总额 / #3 累计交易笔数 / #5 累计 GMV(原无 + 0 调用)→ 不写 +- #4 储值卡余额(原有 + 多处调用)→ 写 thin wrapper + +**长远走向**:F1-6 全部迁完后,所有老调用方逐步改成 `from sandbox_replay import ...`, +`fdw_queries.py` 退化成纯 ETL 物理访问层(`_fdw_context` + 直接 SQL helper),不再承载业务指标接口。 +thin wrapper 一次性清理可放到"sandbox_replay 收尾 sprint"(F1-7+ 或更晚,本 spec §11.5 待定)。 + +**新功能/新接口判断**:不要因为"风格一致"在 fdw_queries 加 0 调用方的空 thin wrapper(churn,违反改动最小化原则)。 + ## 六、阶段 B 前置依赖清单 | 依赖 | 来源 | 状态 | diff --git a/docs/_overview/wave1-findings/F1-6-tasks.md b/docs/_overview/wave1-findings/F1-6-tasks.md index 06dc1aa..9748ea6 100644 --- a/docs/_overview/wave1-findings/F1-6-tasks.md +++ b/docs/_overview/wave1-findings/F1-6-tasks.md @@ -77,11 +77,11 @@ | # | 指标 | 当前实现位置 | 目标迁移到 | 复杂度 | 状态 | |---|------|-------------|----------|------|------| -| 1 | 60 天消费 | `fdw_queries.get_consumption_60d` | `sandbox_replay/consumption_replay.py`(扩展) | S | ✅ 2026-05-06 | -| 2 | 累计消费总额 | (待补,目前无独立查询) | `sandbox_replay/consumption_replay.py`(扩展) | S | ⏳ 待启动 | -| 3 | 累计交易笔数 | (待补,后端无实现) | `sandbox_replay/consumption_replay.py`(扩展) | S | ⏳ 待启动 | +| 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 | ⏳ 待启动 | -| 5 | 累计 GMV | `board_service` 财务总额 | `sandbox_replay/finance_replay.py`(新建) | S | ⏳ 待启动 | +| 5 | 累计 GMV | `dws_finance_daily_summary.gross_amount`(门店级,与现有"区间 GMV"语义不同) | `sandbox_replay/finance_replay.py`(新建) | S | ⏳ 待启动 | ### Sprint 2 实施模式 所有指标走 `@runtime_aware` decorator + `app.v_dws_*` 视图查询。每个指标: @@ -91,7 +91,9 @@ - 审计 + 单独 commit ### Sprint 2 commit -- #1 60d 消费 — `feat(backend): F1-6 sprint2 #1 60d 消费迁移 sandbox_replay`(待提交) +- #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`) ### 估算 5 指标 × 30-50min = 3-4h(#1 实际 ~ 40min) diff --git a/docs/audit/audit_dashboard.md b/docs/audit/audit_dashboard.md index edde0f4..727cc57 100644 --- a/docs/audit/audit_dashboard.md +++ b/docs/audit/audit_dashboard.md @@ -1,12 +1,13 @@ # 审计一览表 -> 自动生成于 2026-05-06 00:23:40,请勿手动编辑。 +> 自动生成于 2026-05-06 00:51:54,请勿手动编辑。 ## 时间线视图 | 日期 | 项目 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 | |------|------|----------|----------|----------|------|------| | 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 #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) | | 2026-05-05 | 项目级 | Wave 1 F1-5a — 沙箱 batch-run 接入 runtime_context(MVP + 漂移防御核心) | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_sandbox_batch_run.md) | @@ -290,6 +291,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 #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) | | 2026-05-05 | Wave 1 F1-5a — 沙箱 batch-run 接入 runtime_context(MVP + 漂移防御核心) | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_sandbox_batch_run.md) | @@ -462,6 +464,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 #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) | | 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_consume_amount.md b/docs/audit/changes/2026-05-06__f1_6_sprint2_total_consume_amount.md new file mode 100644 index 0000000..64594ec --- /dev/null +++ b/docs/audit/changes/2026-05-06__f1_6_sprint2_total_consume_amount.md @@ -0,0 +1,104 @@ +# 2026-05-06 · F1-6 Sprint 2 #2 — 累计消费总额加入 sandbox_replay + +> F1-6 沙箱时光机阶段 B Sprint 2 #2(详见 `docs/_overview/wave1-findings/F1-6-tasks.md` §3) +> +> 工作量评估 S / 30-50min(实际 ~ 30min,无 UI 走查节省时间) +> +> Sprint 2 进度:#1 60d 消费 ✅ → **#2 累计消费总额 ☚** → #3 累计交易笔数(字段未定,需 Neo 决断)→ #4 储值卡余额 → #5 累计 GMV +> +> 完整模块 spec:`docs/_overview/sandbox-replay-engine-spec.md`(§5.5 thin wrapper 决策原则已记录) + +## 背景 + +#1 60d 消费完成后,按方案 A 顺序推进 #2 累计消费总额。本指标在后端 / 4 个前端**全无现有调用方**,Neo 同意接受"无 UI 走查,仅 unit test + 直接 Python 双口径数值验证",理由: +1. AI app7 客户分析 prompt 拼接 可能传入此值 +2. 即将迭代的新版本会使用这些数据 + +## 改动清单 + +### Step 3 实施 + +**修改文件**(2 个,本指标**无 thin wrapper**): +- [apps/backend/app/services/sandbox_replay/consumption_replay.py](apps/backend/app/services/sandbox_replay/consumption_replay.py) — 追加 `get_total_consume_amount` 函数(`@trace_service + @runtime_aware`) +- [apps/backend/app/services/sandbox_replay/__init__.py](apps/backend/app/services/sandbox_replay/__init__.py) — re-export `get_total_consume_amount_replay` + +**未改文件**: +- `apps/backend/app/services/fdw_queries.py` — **不加 thin wrapper**(原无同名查询 + 0 调用方,加空 wrapper 违反改动最小化原则,详见 spec §5.5 决策原则) + +**测试文件**(本地不入仓 `.gitignore:71`): +- [apps/backend/tests/test_sandbox_replay_sprint2.py](apps/backend/tests/test_sandbox_replay_sprint2.py) — 累计 9 case(本指标 4 case) + +测试覆盖: +- 正常返回 Decimal +- 无快照行返回 None +- `total_consume_amount` 字段为 NULL 返回 None +- sandbox 模式 SQL 显式带 `stat_date <= ctx.business_date` 上界 + +合并 sprint 1 + sprint 2 #1 测试套件:**19/19 PASS,无回归**。 + +### Step 4 双口径数值验证(无 UI,直接 Python 调用) + +**目标 member**: 2799207087163141(黄先生) + +**口径**(`BD_manual_dws_member_consumption_summary.md` §金额口径): +``` +total_consume_amount 基于 items_sum 口径: +items_sum = table_charge_money + goods_money + assistant_pd_money + + assistant_cx_money + electricity_money +``` + +**双口径结果**: + +| 维度 | 4a live (today=2026-05-05) | 4b sandbox=2026-04-20 | +|------|---------------------------|----------------------| +| stat_date 数据 | 2026-05-01(真实) | 2026-04-15(walkthrough 测试快照) | +| `total_consume_amount` 字段值 | 1252.65 | 999.99(测试值) | +| `get_total_consume_amount` 返回 | **1252.65** ✓ | **999.99** ✓ | + +走查脚本(归档): +- `_DEL/walkthrough_f1_6/step_sprint2_total_consume_4a_4b.py` — 一键完成 4a 验证 + sandbox 切换 + 4b 验证 + 清理 + +**关键证据**:sandbox 切换时,`get_total_consume_amount` 函数读 `ctx.business_date=2026-04-20` 作为 SQL 上界,视图层同时按 `stat_date <= app.business_date_now()=2026-04-20` 过滤,双重保障下取 stat_date=2026-04-15 行的 `total_consume_amount=999.99`,完全符合"沙箱时光机"语义。 + +测试快照已自动清理(脚本末尾 DELETE),sandbox 已切回 live。 + +### Step 5 审计 + +本文件 + F1-6-tasks.md Sprint 2 进度推进 + spec §5.5 thin wrapper 原则记录。 + +## 影响范围 + +| 端 | 影响 | 验证 | +|----|------|------| +| 后端 sandbox_replay.consumption_replay | 新增 `get_total_consume_amount` 函数 | unit test 4/4 + 累计 19/19 PASS | +| 后端 fdw_queries | **无影响**(不加 thin wrapper) | — | +| 后端 customer_service / board / 其他 | 无影响(0 现有调用方) | — | +| 小程序 / admin-web / 租户后台 / ETL | 无影响(0 现有调用方) | — | + +## 测试 + +- 后端 unit test 19/19 PASS(本地 `apps/backend/tests/test_sandbox_replay_sprint{1,2}.py`,因 `.gitignore:71` 不入仓) +- 直接 Python 调用 4a/4b 双口径 PASS(1252.65 vs 999.99) +- **无 UI 走查**(本指标 0 调用方,Neo 已确认接受) + +## 风险与未覆盖 + +- **无 UI 走查**:Neo 已认可,理由是 AI app7 + 新版本迭代场景。该指标接入到真实业务调用方时(未来),应补做 UI 走查。 +- **不加 thin wrapper 与 #1 模式不一致**:已在 spec §5.5 记录决策原则(不一致是正确的,改动最小化原则)。 +- **未来调用方接入路径**:`from app.services.sandbox_replay.consumption_replay import get_total_consume_amount`,直接 import 不绕 fdw_queries。 + +## 回滚策略 + +```bash +git revert +``` + +回滚后: +- `sandbox_replay/consumption_replay.py` 删除 `get_total_consume_amount` 函数 +- `sandbox_replay/__init__.py` 移除 `get_total_consume_amount_replay` re-export +- 0 调用点无影响(本指标无现有调用方) +- 测试文件本地保留(`.gitignore` 范围内) + +## Co-Authored-By + +Claude Opus 4.7 (1M context)