From e78bbaaf761517d23adbfd49bfe2ab379695b155 Mon Sep 17 00:00:00 2001 From: win Date: Sat, 21 Mar 2026 17:17:56 +0800 Subject: [PATCH] docs(phase-1): add research and validation strategy --- .../01-core-pnl-functions/01-RESEARCH.md | 675 ++++++++++++++++++ .../01-core-pnl-functions/01-VALIDATION.md | 77 ++ 2 files changed, 752 insertions(+) create mode 100644 .planning/phases/01-core-pnl-functions/01-RESEARCH.md create mode 100644 .planning/phases/01-core-pnl-functions/01-VALIDATION.md diff --git a/.planning/phases/01-core-pnl-functions/01-RESEARCH.md b/.planning/phases/01-core-pnl-functions/01-RESEARCH.md new file mode 100644 index 0000000..1e08d1f --- /dev/null +++ b/.planning/phases/01-core-pnl-functions/01-RESEARCH.md @@ -0,0 +1,675 @@ +# Phase 1: Core P&L Functions - Research + +**Researched:** 2026-03-21 +**Domain:** Go service layer financial aggregation — multi-dimensional P&L query functions in an existing GORM/MySQL codebase +**Confidence:** HIGH + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**Revenue Attribution Rules** +- D-01: 一个订单只对应一个活动(1:1 关系),不需要比例分摊逻辑(跳过 dashboard 中的 two-level subquery 方案) +- D-02: Game-pass 收入按 draw_count × activity_unit_price 计算,每个活动独立计算 +- D-03: 用户维度直接汇总用户所有订单,不做跨活动分摊 + +**Function Signature Design** +- D-04: 两个独立的参数结构体:`UserProfitLossParams` 和 `ActivityProfitLossParams`(不共享) +- D-05: 返回 `(*ProfitLossResult, error)` — Go 标准模式,error 时 result 为 nil +- D-06: ProfitLossResult 包含汇总(TotalResult)+ 明细切片(`[]ProfitLossDetail`,每个元素含 UserID/ActivityID 字段) +- D-07: 参数全部可选:空 []int64 = 统计全部,nil time = 不限时间,AssetType=0 = 全部类型 + +**Cost Source Mapping** +- D-08: 成本数据分布在多张表:user_inventory(实物/道具卡)、user_points_ledger(积分)、user_coupon_ledger(优惠券)、fragment_synthesis_logs(碎片,Phase 2) +- D-09: 实物商品/道具卡成本以 `user_inventory.value_cents` 为准(单一真相源),不需要 fallback chain +- D-10: 积分通过 system_configs 表中的固定汇率换算为金额(如 100积分 = 1元) +- D-11: 优惠券成本 = 优惠券面值(discount_amount) + +### Claude's Discretion +- 具体 SQL 查询结构和 GORM 调用方式 +- ProfitLossDetail 内部字段的精确命名 +- fan-out 查询的拆分粒度和合并策略 +- 单元测试的具体用例选择 + +### Deferred Ideas (OUT OF SCOPE) +- Per-asset-type breakdown (Phase 2) — struct field defined here as empty slice, populated in Phase 2 +- Fragment synthesis cost integration (Phase 2) — AST-03 +- Redis caching wrapper (v2) +- Admin API endpoints for frontend (v2) + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| PNL-01 | 函数接收 ProfitLossParams 参数结构体,所有字段可选(资产类型、维度ID、时间范围) | D-04, D-07; param struct pattern confirmed from user.go constructor pattern | +| PNL-02 | Revenue = actual_amount + discount_amount,排除已退款/取消订单(status=3,4) | Confirmed from dashboard_activity.go:209; status=2 filter is the correct paid-only gate | +| PNL-03 | Game-pass 订单通过 finance.IsGamePassOrder 三条件检测,与现金收入严格互斥 | finance.IsGamePassOrder verified in profit_metrics.go:43-51; 3 conditions documented | +| PNL-04 | Game-pass 订单收入通过 finance.ComputeGamePassValue 计算(draw_count × activity_price) | finance.ComputeGamePassValue verified in profit_metrics.go:53-58 | +| PNL-05 | Prize cost 通过 finance.ComputePrizeCostWithMultiplier 计算,包含道具卡倍率 | finance.ComputePrizeCostWithMultiplier verified in profit_metrics.go:67-73 | +| PNL-06 | Profit 通过 finance.ComputeProfit 计算,返回 int64 分 + float64 利润率 | finance.ComputeProfit verified in profit_metrics.go:75-81 | +| PNL-07 | 排除已作废库存(remark LIKE '%void%' 或 status=2)不计入成本 | Pattern in dashboard_activity.go:248-249; status IN (1,3) + remark NOT LIKE '%void%' | +| PNL-08 | 兼容 order_id=0 或 NULL 的历史数据(不受订单状态过滤影响) | Pattern in dashboard_activity.go:251; `OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL` | +| DIM-01 | QueryUserProfitLoss 接收 []int64 用户ID,空切片=统计全部用户 | GORM WHERE IN with empty slice guard; empty = omit WHERE clause | +| DIM-02 | QueryActivityProfitLoss 接收 []int64 活动ID,空切片=统计全部活动 | Same pattern as DIM-01 | +| DIM-03 | 时间范围过滤使用 *time.Time(nil=不限),不使用零值作哨兵 | Confirmed; *time.Time pointer pattern is idiomatic in this codebase | +| DIM-04 | AssetType 可选过滤,nil/All(0)=统计全部资产类型 | AssetTypeAll=0 as zero value is natural Go default | +| RET-01 | ProfitLossResult 包含汇总数据:总收入、总成本、净盈亏、利润率 | Struct fields verified against finance.ComputeProfit return values | +| RET-03 | 所有金额以 int64 分为单位,不使用 float64 存储金额 | Codebase-wide convention confirmed; only profit_rate is float64 | +| AST-01 | 定义 AssetType 枚举:Points(1)、Coupon(2)、ItemCard(3)、Product(4)、Fragment(5)、All(0) | Clean iota-style const block; All=0 as zero value | +| QUA-01 | 新函数放在 internal/service/finance/ 包下 | Package already exists with profit_metrics.go | +| QUA-02 | Service 构造器仅注入 DbR(读库),包内不出现 GetDbW() 调用 | Pattern from user.go New() func; finance service omits writeDB field entirely | +| QUA-03 | 每个 Scan() 调用必须检查 .Error 并返回错误,不静默吞掉 | Critical pattern; dashboard had this bug; new code must not repeat it | +| QUA-04 | 复用现有 finance.* 工具函数,不重复实现 | All 6 functions verified in profit_metrics.go; reuse confirmed | +| QUA-05 | 使用 fan-out + in-memory merge 查询模式 | Pattern from dashboard_activity.go; multiple Scan() calls merged via map[int64]*item | + + +--- + +## Summary + +This phase adds two new service-layer functions — `QueryUserProfitLoss` and `QueryActivityProfitLoss` — to the existing `internal/service/finance/` package. The codebase already has all the building blocks: six tested pure functions (`profit_metrics.go`), established fan-out query patterns (`dashboard_activity.go`), a SQLite test infrastructure, and clear conventions for constructor injection and error handling. + +The key insight from reviewing the existing dashboard code is that the new service functions are *simpler* than the dashboard handlers because D-01 locks the 1:1 order-to-activity rule, eliminating the two-level subquery revenue proration that the dashboard requires. Revenue for the user dimension is a direct `SUM(actual_amount + discount_amount)` on the user's orders; for the activity dimension it is a direct `SUM` filtered to that activity's orders. + +Cost still requires the same multi-join pattern as the dashboard: `user_inventory` joined to `orders` (for refund exclusion), `activity_reward_settings` and `products` (for price fallback chain per D-09 — **note: D-09 says value_cents is the single source of truth**, so the fallback chain simplifies to just `user_inventory.value_cents`), and `system_item_cards` (for multiplier). Points cost requires a separate scan on `user_points_ledger` converted via the `points.exchange_rate` system config. Coupon cost requires a scan on `user_coupon_ledger` for deduction amounts. + +**Primary recommendation:** Implement as five new files in `internal/service/finance/`: `service.go` (interface + constructor), `types.go` (params, result structs, AssetType enum), `query_user.go`, `query_activity.go`, and `service_test.go`. Each query file executes 3-4 fan-out scans and merges in Go using the established map pattern. + +--- + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| gorm.io/gorm | 1.25.9 | ORM query execution | Already in project; `.Table().Select().Scan()` pattern used throughout | +| go.uber.org/zap (via logger.CustomLogger) | 1.26.0 | Structured error logging | Project-standard logger interface; injected via constructor | +| gorm.io/driver/sqlite | 1.4.3 | In-memory test DB | `NewSQLiteRepoForTest()` already exists in testrepo_sqlite.go | +| github.com/stretchr/testify | 1.11.1 | Test assertions | Project-standard test library | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| bindbox-game/internal/pkg/points | local | Points ↔ cents conversion | When computing points cost in cents; `PointsToCents(pts, rate)` | +| bindbox-game/internal/service/finance | local (same pkg) | Reusable finance primitives | All 6 functions from profit_metrics.go | + +**No new dependencies required.** All libraries are in `go.mod`. + +--- + +## Architecture Patterns + +### Recommended Project Structure + +``` +internal/service/finance/ +├── profit_metrics.go (EXISTING — pure business logic, no DB) +├── profit_metrics_test.go (EXISTING — pure unit tests) +├── service.go (NEW — Service interface + New() constructor) +├── types.go (NEW — AssetType enum, param structs, result types) +├── query_user.go (NEW — QueryUserProfitLoss scan logic, 3-4 Scan calls) +├── query_activity.go (NEW — QueryActivityProfitLoss scan logic, 3-4 Scan calls) +└── service_test.go (NEW — integration tests using NewSQLiteRepoForTest()) +``` + +### Pattern 1: Service Constructor (Read-Only DB Injection) + +**What:** Constructor injects only the read replica `*gorm.DB`; the finance service struct has no `writeDB` field. + +**When to use:** Always — QUA-02 mandates no `GetDbW()` in this package. + +```go +// Source: internal/service/user/user.go (constructor pattern reference) +package finance + +import ( + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/repository/mysql" + "gorm.io/gorm" +) + +type Service interface { + QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) + QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error) +} + +type service struct { + logger logger.CustomLogger + dbR *gorm.DB // read replica only — never use for writes +} + +func New(l logger.CustomLogger, db mysql.Repo) Service { + return &service{ + logger: l, + dbR: db.GetDbR(), + } +} +``` + +### Pattern 2: Fan-Out + In-Memory Merge + +**What:** Execute N independent `Scan()` calls (one per data source), then merge results in Go using `map[int64]*ProfitLossDetail`. + +**When to use:** Any query requiring data from multiple tables that have 1-to-many relationships (avoids Cartesian product JOINs). + +```go +// Source: internal/api/admin/dashboard_activity.go (fan-out pattern reference) + +// Step 1: revenue scan +type revenueRow struct { + DimensionID int64 + TotalRevenue int64 + TotalGamePassValue int64 +} +var revenueRows []revenueRow +if err := db.Table(model.TableNameOrders). + Select(` + orders.user_id as dimension_id, + SUM(CASE WHEN source_type = 4 OR order_no LIKE 'GP%' OR (actual_amount = 0 AND remark LIKE '%use_game_pass%') + THEN 0 + ELSE actual_amount + discount_amount + END) as total_revenue + `). + Where("orders.status = ?", 2). + Group("orders.user_id"). + Scan(&revenueRows).Error; err != nil { + return nil, fmt.Errorf("revenue scan failed: %w", err) +} + +// Step 2: cost scan (separate query) +type costRow struct { + DimensionID int64 + TotalCost int64 +} +var costRows []costRow +if err := db.Table(model.TableNameUserInventory). + Select(` + user_inventory.user_id as dimension_id, + SUM(user_inventory.value_cents) as total_cost + `). + Where("user_inventory.status IN ?", []int{1, 3}). + Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%"). + Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2). + Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id"). + Group("user_inventory.user_id"). + Scan(&costRows).Error; err != nil { + return nil, fmt.Errorf("cost scan failed: %w", err) +} + +// Step 3: merge in Go +resultMap := make(map[int64]*ProfitLossDetail) +for _, r := range revenueRows { + resultMap[r.DimensionID] = &ProfitLossDetail{ + UserID: r.DimensionID, + Revenue: r.TotalRevenue, + } +} +for _, c := range costRows { + if item, ok := resultMap[c.DimensionID]; ok { + item.Cost = c.TotalCost + } +} + +// Step 4: apply finance functions +for _, item := range resultMap { + item.Profit, item.ProfitRate = ComputeProfit(item.Revenue, item.Cost) +} +``` + +### Pattern 3: Optional Parameter Filtering + +**What:** Build up the GORM query conditionally; only add WHERE clauses when params are non-nil/non-empty. + +**When to use:** All query functions in this service — DIM-01 through DIM-04. + +```go +// Source: established codebase pattern +func (s *service) buildBaseQuery(ctx context.Context, params UserProfitLossParams) *gorm.DB { + db := s.dbR.WithContext(ctx).Table(model.TableNameOrders) + + // Empty slice = no filter (all records) + if len(params.UserIDs) > 0 { + db = db.Where("orders.user_id IN ?", params.UserIDs) + } + if params.StartTime != nil { + db = db.Where("orders.created_at >= ?", *params.StartTime) + } + if params.EndTime != nil { + db = db.Where("orders.created_at <= ?", *params.EndTime) + } + return db +} +``` + +### Pattern 4: Points Cost Resolution + +**What:** Read `points.exchange_rate` from `system_configs`, then convert points deductions from `user_points_ledger` to cents. + +**When to use:** When computing points cost contribution (cost data from `user_points_ledger`). + +```go +// Source: internal/service/user/points_convert.go (getExchangeRate pattern) +func (s *service) getPointsExchangeRate(ctx context.Context) int64 { + var cfg model.SystemConfigs + if err := s.dbR.WithContext(ctx). + Where("config_key = ?", "points.exchange_rate"). + First(&cfg).Error; err != nil { + return 1 // default: 1 yuan = 1 point + } + var rate int64 + _, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &rate) + if rate <= 0 { + return 1 + } + return rate +} + +// Convert points to cents: cents = points * 100 / rate +// Source: internal/pkg/points/convert.go PointsToCents() +pointsCostCents := points.PointsToCents(totalPointsDeducted, float64(exchangeRate)) +``` + +### Pattern 5: CAST(AS SIGNED) for Division SUM + +**What:** Wrap any SUM expression containing division with `CAST(... AS SIGNED)` to prevent MySQL returning Decimal type. + +**When to use:** Any SQL aggregation involving division in SUM. + +**Note:** D-09 locks `user_inventory.value_cents` as the single source of truth for inventory cost, so the fallback COALESCE chain from the dashboard is NOT used. The cost formula simplifies to `SUM(user_inventory.value_cents * multiplier / 1000)` which still requires CAST. + +```go +// Source: internal/api/admin/dashboard_activity.go:237 (CAST pattern) +// MySQL returns DECIMAL for SUM(x * y / z) — must cast to SIGNED for int64 scan +Select(` + CAST(SUM( + user_inventory.value_cents + * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) + / 1000 + ) AS SIGNED) as total_cost +`) +``` + +### Pattern 6: Game-Pass Revenue Calculation + +**What:** Use `IsGamePassOrder()` to classify orders. Game-pass revenue = `draw_count × activity_price`. Cash revenue = `actual_amount + discount_amount`. These are mutually exclusive (D-03). + +**When to use:** Every revenue scan in both query functions. + +**Key simplification vs. dashboard:** Since D-01 establishes 1:1 order-to-activity, there is NO need for the draw-count proration subquery that the dashboard uses. Revenue is directly attributable to the order's user/activity. + +```go +// For user dimension: classify per-order in Go after scanning raw fields +// Scan raw fields: source_type, order_no, actual_amount, discount_amount, remark, draw_count, activity_price +// Then call: ClassifyOrderSpending(sourceType, orderNo, actualAmount, discountAmount, remark, gpValue) +// Sum breakdown.Total as dimension revenue +``` + +### Anti-Patterns to Avoid + +- **Mega-JOIN across all tables:** Produces Cartesian products. Use fan-out separate Scan calls instead. +- **Scanning division-SUM into int64 without CAST:** Returns zero silently (MySQL Decimal → int64 mismatch). +- **Skipping `.Error` check on Scan():** Silent wrong data. Every Scan must check error. +- **Using GetDbW() in this package:** Violates QUA-02 and adds load to write master. +- **Using `time.Time{}` zero value as "no filter" sentinel:** Use `*time.Time`; nil = no filter. +- **Passing empty slice to `WHERE IN (?)`:** GORM generates invalid SQL. Guard with `if len(ids) > 0` before adding the WHERE clause. +- **Re-implementing game-pass classification logic in SQL CASE expressions:** Duplicates `IsGamePassOrder()` and diverges from the canonical rule. Scan raw fields, classify in Go. +- **COALESCE fallback chain for value_cents:** D-09 locks `user_inventory.value_cents` as single truth source — do NOT use the dashboard's `COALESCE(NULLIF(value_cents,0), price_snapshot_cents, products.price, 0)`. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Game-pass order classification | Custom CASE expression in SQL or new Go function | `finance.IsGamePassOrder()` | Three conditions; already tested; must stay in sync across codebase | +| Game-pass value calculation | `drawCount * price` inline everywhere | `finance.ComputeGamePassValue()` | Guards against zero/negative inputs | +| Prize cost with multiplier | Custom multiplication in query | `finance.ComputePrizeCostWithMultiplier()` | Handles multiplier normalization (GREATEST/default 1000) | +| Profit + profit rate calculation | `revenue - cost` inline | `finance.ComputeProfit()` | Handles zero-revenue edge case (avoids division by zero) | +| Points-to-cents conversion | Custom formula | `points.PointsToCents(pts, rate)` | Handles rounding via `math.Round`; tested | +| Exchange rate lookup | Hardcode or re-implement | Pattern from `user.getExchangeRate()` | Reads from `system_configs` table with safe default | +| Test database | Real MySQL connection | `mysql.NewSQLiteRepoForTest()` | In-memory, zero-config, existing infrastructure | + +**Key insight:** The `internal/service/finance/` package already contains the entire mathematical foundation. This phase is a database query layer on top, not a business logic reimplementation. + +--- + +## Common Pitfalls + +### Pitfall 1: MySQL SUM with Division Returns Decimal (Silent Zero) + +**What goes wrong:** `SUM(value_cents * multiplier / 1000)` returns Decimal type in MySQL. GORM scan into `int64` silently produces 0. Cost appears as 0 even with data. + +**Why it happens:** MySQL promotes arithmetic involving division to Decimal to preserve fractional precision. GORM does not coerce types. + +**How to avoid:** Wrap the entire SUM expression with `CAST(... AS SIGNED)`. Applies specifically to the multiplier cost calculation. + +**Warning signs:** Cost fields are uniformly 0 across all activities/users despite inventory data existing. + +### Pitfall 2: Empty Slice in WHERE IN Produces Invalid SQL + +**What goes wrong:** `db.Where("user_id IN ?", []int64{})` generates `WHERE user_id IN ()` — invalid SQL that returns error or empty result instead of all records. + +**Why it happens:** GORM does not guard against empty slice inputs. + +**How to avoid:** Always check `len(ids) > 0` before adding the WHERE clause. Empty slice means "all records" per D-07 — do not add the filter at all. + +**Warning signs:** Function returns empty result or SQL error when called with no IDs. + +### Pitfall 3: Game-Pass and Cash Revenue Double-Counted + +**What goes wrong:** Including both `actual_amount + discount_amount` AND game-pass value for the same order. Game-pass orders have `actual_amount = 0`, so their coupon-based revenue is 0, but their game-pass value is not — adding both produces correct total by accident, but the classification is wrong and subtotals diverge from dashboard. + +**Why it happens:** Treating all orders uniformly in a single SUM. + +**How to avoid:** Scan both raw order fields AND activity price/draw_count. Classify per-order in Go with `ClassifyOrderSpending()`. The mutual exclusion is enforced by the function. + +**Warning signs:** `SpendingPaidCoupon` and `SpendingGamePass` are both non-zero for the same order-level scan. + +### Pitfall 4: Refunded Order Inventory Counted as Cost + +**What goes wrong:** `user_inventory` rows exist for prizes awarded from subsequently-refunded orders. Counting them inflates cost while excluding their revenue. + +**Why it happens:** Inventory is created on award (before refund window). Refunds update `orders.status` to 4, not delete inventory. + +**How to avoid:** Always join `orders` and filter `(orders.status = 2 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)`. The legacy escape hatch (`order_id = 0 OR NULL`) is mandatory for old data compatibility (PNL-08). + +**Warning signs:** Platform appears to have given away prizes for free; test with a refunded order shows non-zero cost. + +### Pitfall 5: Silently Ignored Scan Errors + +**What goes wrong:** If a Scan fails (schema mismatch, DB failover), the result struct stays at zero values. No error is returned. The P&L appears correct (all zeros) rather than failing. + +**Why it happens:** GORM method chaining makes it easy to omit `.Error` check. + +**How to avoid:** Every Scan must be: `if err := db...Scan(&result).Error; err != nil { return nil, fmt.Errorf("...: %w", err) }`. This is QUA-03. + +**Warning signs:** Function returns zero P&L with nil error even when data exists; test with a deliberately broken query. + +### Pitfall 6: SQLite Test Incompatibilities + +**What goes wrong:** Tests using `NewSQLiteRepoForTest()` fail because SQLite does not support: +- `CAST(... AS SIGNED)` — use `CAST(... AS INTEGER)` in test SQL or compute in Go +- `GREATEST()` MySQL function — not available in SQLite +- `LIKE 'GP%'` may behave differently in edge cases + +**Why it happens:** Integration tests use SQLite for speed/simplicity but production uses MySQL. + +**How to avoid:** Keep game-pass classification in Go (scan raw fields, call `IsGamePassOrder()` in Go). Keep multiplier application in Go (scan raw `value_cents` and `multiplier_x1000`, call `ComputePrizeCostWithMultiplier()` in Go). Only perform non-division aggregations in SQL for tests. + +**Warning signs:** Tests pass locally with Go-layer classification but fail when logic is moved into SQL CASE expressions. + +--- + +## Code Examples + +Verified patterns from codebase analysis: + +### Service Constructor + +```go +// Source: internal/service/user/user.go:100-102 (reference pattern) +// Finance service omits writeDB entirely (QUA-02) +func New(l logger.CustomLogger, db mysql.Repo) Service { + return &service{ + logger: l, + dbR: db.GetDbR(), + } +} +``` + +### Param Structs and Types + +```go +// Source: CONTEXT.md D-04, D-07; STACK.md pattern +type AssetType int + +const ( + AssetTypeAll AssetType = 0 // zero value = all types + AssetTypePoints AssetType = 1 + AssetTypeCoupon AssetType = 2 + AssetTypeItemCard AssetType = 3 + AssetTypeProduct AssetType = 4 + AssetTypeFragment AssetType = 5 +) + +type UserProfitLossParams struct { + UserIDs []int64 // empty = all users (DIM-01) + AssetType AssetType // 0 = all types (DIM-04) + StartTime *time.Time // nil = no lower bound (DIM-03) + EndTime *time.Time // nil = no upper bound (DIM-03) +} + +type ActivityProfitLossParams struct { + ActivityIDs []int64 // empty = all activities (DIM-02) + AssetType AssetType // 0 = all types (DIM-04) + StartTime *time.Time // nil = no lower bound (DIM-03) + EndTime *time.Time // nil = no upper bound (DIM-03) +} +``` + +### Result Structs + +```go +// Source: CONTEXT.md D-05, D-06; REQUIREMENTS.md RET-01, RET-03 +type ProfitLossDetail struct { + UserID int64 // populated for user dimension + ActivityID int64 // populated for activity dimension + Revenue int64 // fen (RET-03: int64 only) + Cost int64 // fen + Profit int64 // fen + ProfitRate float64 // ratio (only field that uses float64) +} + +type ProfitLossResult struct { + TotalRevenue int64 // fen (RET-01) + TotalCost int64 // fen + TotalProfit int64 // fen + ProfitRate float64 // ratio + Details []ProfitLossDetail // per-user or per-activity (D-06) + Breakdown []interface{} // Phase 2: empty slice placeholder (deferred) +} +``` + +### Revenue Query (User Dimension, No Proration) + +```go +// Source: CONTEXT.md D-01, D-03; simplification vs dashboard_activity.go +// No two-level subquery needed — 1:1 order-to-activity (D-01) +type userRevenueRow struct { + UserID int64 + CashRevenue int64 // actual_amount + discount_amount for non-game-pass orders + GamePassDraws int64 // draw count for game-pass orders + ActivityPriceDraw int64 // unit price of the activity (for game-pass value calc) +} +// NOTE: Game-pass value = GamePassDraws × ActivityPriceDraw, computed in Go +// using ComputeGamePassValue() — not computed in SQL to maintain SQLite test compat +``` + +### Cost Query (Inventory, User Dimension) + +```go +// Source: dashboard_activity.go:234-263 (adapted per D-09: value_cents only, no fallback chain) +type userCostRow struct { + UserID int64 + TotalCostCents int64 // CAST(SUM(value_cents * multiplier / 1000) AS SIGNED) +} +// Note: CAST required for division-containing SUM (Pitfall 1) +// Note: status IN (1,3) + remark NOT LIKE '%void%' + legacy order_id=0 guard (PNL-07, PNL-08) +``` + +### Error Handling Pattern + +```go +// Source: PITFALLS.md Pitfall 4; QUA-03 +var rows []revenueRow +if err := s.dbR.WithContext(ctx). + Table(model.TableNameOrders). + Select("..."). + Where("orders.status = ?", 2). + Group("orders.user_id"). + Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("QueryUserProfitLoss revenue scan: %w", err) +} +``` + +### Points Cost Resolution + +```go +// Source: internal/service/user/points_convert.go:13-25 +// Read exchange rate from system_configs, convert points ledger deductions to cents +var pointRows []struct { + UserID int64 + TotalPoints int64 // SUM of negative point changes = cost +} +// After scan: +rate := s.getPointsExchangeRate(ctx) // reads "points.exchange_rate" key +for _, r := range pointRows { + costCents := points.PointsToCents(r.TotalPoints, float64(rate)) + resultMap[r.UserID].Cost += costCents +} +``` + +### Test Setup + +```go +// Source: internal/repository/mysql/testrepo_sqlite.go +func TestQueryUserProfitLoss(t *testing.T) { + repo, err := mysql.NewSQLiteRepoForTest() + require.NoError(t, err) + + // Create tables with AutoMigrate + db := repo.GetDbR() + require.NoError(t, db.AutoMigrate(&model.Orders{}, &model.UserInventory{}, ...)) + + // Seed test data + // ... + + svc := New(logger.NewCustomLogger(nil, logger.WithOutputInConsole()), repo) + result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{}) + require.NoError(t, err) + // assert... +} +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Single mega-JOIN across orders + inventory + draw_logs | Fan-out separate Scan calls merged in Go | Dashboard v2 (already in codebase) | Eliminates Cartesian product; individual queries are independently testable | +| Revenue attributed by scanning orders directly in handler | Service-layer function with injected DB and typed params | This phase | Callers don't need to write SQL; consistent calculation across all endpoints | +| Dashboard handlers as source of truth for P&L numbers | `finance.*` utility functions + new service layer | This phase | Decoupled from HTTP context; reusable from any caller | +| float64 scan for SUM-with-division | CAST(... AS SIGNED) + int64 scan | Dashboard bugfix (already in code) | Eliminates floating-point monetary rounding | + +**Deprecated/outdated:** +- Fallback COALESCE chain for `value_cents` — D-09 deprecates this in the new service (dashboard still uses it for backward compat; new service uses `value_cents` directly) +- Inline game-pass CASE expressions in SQL — deprecated in favor of Go-layer classification via `IsGamePassOrder()` + +--- + +## Open Questions + +1. **Points ledger: which `action` values represent cost deductions?** + - What we know: `user_points_ledger.action` includes `order_deduct`, `refund_restore`, `signin`, `manual` + - What's unclear: Should only `order_deduct` actions count as cost? Or all negative-delta entries? + - Recommendation: Filter on `action = 'order_deduct'` AND `points < 0` for cost. Refund restores (`refund_restore`) should cancel the cost — verify by checking if the net sum correctly cancels on refund. + +2. **Coupon cost: which `user_coupon_ledger.action` values represent platform cost?** + - What we know: `user_coupon_ledger` has `change_amount` (negative = deduction), `order_id`, `action` + - What's unclear: Is `SUM(ABS(change_amount)) WHERE change_amount < 0` the correct cost formula, or should we filter by action? + - Recommendation: Sum all deductions (`change_amount < 0`) for orders with status=2 (paid). Join to orders table to filter refunded orders. + +3. **Activity price for game-pass value: which field is authoritative?** + - What we know: D-02 says `draw_count × activity_unit_price`. `activities.price_draw` is the per-draw price used in the dashboard. + - What's unclear: For the user dimension query, orders may span multiple activities. Does each order carry the activity's price at order time, or must we join to `activities`? + - Recommendation: Join `orders` → `activity_draw_logs` → `activity_issues` → `activities` to get `activities.price_draw`. This is the same join the dashboard uses for game-pass value (dashboard_activity.go:280-296). + +--- + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Go testing + testify v1.11.1 | +| Config file | none — standard `go test` | +| Quick run command | `go test -v ./internal/service/finance/...` | +| Full suite command | `make test` (runs `go test -v --cover ./internal/...`) | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| PNL-01 | Params struct accepted with all nil/empty fields | unit | `go test -run TestQueryUserProfitLoss_EmptyParams ./internal/service/finance/` | ❌ Wave 0 | +| PNL-02 | Refunded orders excluded from revenue | integration | `go test -run TestQueryUserProfitLoss_RefundedOrderExcluded ./internal/service/finance/` | ❌ Wave 0 | +| PNL-03 | Game-pass orders classified correctly, mutually exclusive with cash | unit | `go test -run TestClassifyOrderSpending ./internal/service/finance/` | ✅ profit_metrics_test.go | +| PNL-04 | Game-pass value = draw_count × activity_price | unit | `go test -run TestComputeGamePassValue ./internal/service/finance/` | ✅ profit_metrics_test.go | +| PNL-05 | Prize cost includes item-card multiplier | unit | `go test -run TestComputePrizeCostWithMultiplier ./internal/service/finance/` | ✅ profit_metrics_test.go | +| PNL-06 | Profit and profit_rate computed correctly | unit | `go test -run TestProfit ./internal/service/finance/` | ✅ profit_metrics_test.go | +| PNL-07 | Voided inventory excluded from cost | integration | `go test -run TestQueryUserProfitLoss_VoidedInventoryExcluded ./internal/service/finance/` | ❌ Wave 0 | +| PNL-08 | Legacy order_id=0 inventory included in cost | integration | `go test -run TestQueryUserProfitLoss_LegacyZeroOrderID ./internal/service/finance/` | ❌ Wave 0 | +| DIM-01 | Empty UserIDs returns all users | integration | `go test -run TestQueryUserProfitLoss_AllUsers ./internal/service/finance/` | ❌ Wave 0 | +| DIM-02 | Empty ActivityIDs returns all activities | integration | `go test -run TestQueryActivityProfitLoss_AllActivities ./internal/service/finance/` | ❌ Wave 0 | +| DIM-03 | *time.Time nil = no time filter | unit | `go test -run TestBuildBaseQuery_NilTime ./internal/service/finance/` | ❌ Wave 0 | +| DIM-04 | AssetType=0 returns all types | unit | `go test -run TestQueryUserProfitLoss_AllAssetTypes ./internal/service/finance/` | ❌ Wave 0 | +| RET-01 | Result includes TotalRevenue, TotalCost, TotalProfit, ProfitRate | integration | `go test -run TestQueryUserProfitLoss_ResultShape ./internal/service/finance/` | ❌ Wave 0 | +| RET-03 | All monetary fields are int64 | compile-time | `go build ./internal/service/finance/` | ❌ Wave 0 | +| AST-01 | AssetType constants defined with correct values | unit | `go test -run TestAssetTypeConstants ./internal/service/finance/` | ❌ Wave 0 | +| QUA-01 | New files in correct package | compile-time | `go build ./internal/service/finance/` | ❌ Wave 0 | +| QUA-02 | No GetDbW() in finance package | static | `grep -r "GetDbW" ./internal/service/finance/` must return empty | ❌ Wave 0 | +| QUA-03 | All Scan() errors checked | code review + test | `go test -run TestQueryUserProfitLoss_ScanError ./internal/service/finance/` | ❌ Wave 0 | +| QUA-04 | finance.* utilities called, not reimplemented | code review | `grep -r "IsGamePassOrder\|ComputeProfit" ./internal/service/finance/query_*.go` | ❌ Wave 0 | +| QUA-05 | Fan-out pattern: multiple Scan calls, merge in Go | code review | `grep -c "Scan" ./internal/service/finance/query_user.go` should be >= 3 | ❌ Wave 0 | + +### Sampling Rate +- **Per task commit:** `go test -v ./internal/service/finance/...` +- **Per wave merge:** `make test` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `internal/service/finance/service_test.go` — all integration tests using SQLiteRepoForTest +- [ ] `internal/service/finance/service.go` — Service interface + New() constructor +- [ ] `internal/service/finance/types.go` — AssetType enum, param structs, result types + +*(Existing `profit_metrics_test.go` covers PNL-03, PNL-04, PNL-05, PNL-06 — no gaps for those)* + +--- + +## Sources + +### Primary (HIGH confidence) +- `internal/service/finance/profit_metrics.go` — All 6 reusable finance functions verified in source +- `internal/service/finance/profit_metrics_test.go` — Existing test patterns confirmed +- `internal/api/admin/dashboard_activity.go` — Fan-out query pattern, CAST(AS SIGNED), refund exclusion, game-pass stats, void exclusion confirmed at lines 146-309 +- `internal/repository/mysql/mysql.go` — Repo interface, GetDbR()/GetDbW() confirmed +- `internal/repository/mysql/testrepo_sqlite.go` — NewSQLiteRepoForTest() confirmed +- `internal/service/user/user.go` — Service interface + constructor pattern confirmed at lines 93-102 +- `internal/service/user/points_convert.go` — getExchangeRate pattern confirmed +- `internal/pkg/points/convert.go` — PointsToCents/CentsToPoints confirmed +- `internal/repository/mysql/model/user_inventory.gen.go` — UserInventory schema: value_cents, status, remark, order_id, activity_id fields +- `internal/repository/mysql/model/user_points_ledger.gen.go` — Points ledger schema confirmed +- `internal/repository/mysql/model/user_coupon_ledger.gen.go` — Coupon ledger schema: change_amount, order_id, action fields +- `.planning/research/PITFALLS.md` — 6 pitfalls with codebase evidence +- `.planning/research/STACK.md` — Query patterns and SQLite compat notes +- `.planning/research/FEATURES.md` — Feature prioritization matrix + +### Secondary (MEDIUM confidence) +- `.planning/phases/01-core-pnl-functions/1-CONTEXT.md` — All locked decisions (D-01 through D-11) +- `.planning/REQUIREMENTS.md` — Requirement definitions + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — entire stack is existing, verified from source files +- Architecture patterns: HIGH — all patterns lifted directly from existing codebase implementations +- Pitfalls: HIGH — derived from existing bug-fix comments in dashboard code plus Go/MySQL behavior +- Open questions: MEDIUM — points/coupon cost query specifics require validation against schema and business intent during implementation + +**Research date:** 2026-03-21 +**Valid until:** 2026-06-21 (stable Go/GORM stack; schema changes would invalidate) diff --git a/.planning/phases/01-core-pnl-functions/01-VALIDATION.md b/.planning/phases/01-core-pnl-functions/01-VALIDATION.md new file mode 100644 index 0000000..6b10b73 --- /dev/null +++ b/.planning/phases/01-core-pnl-functions/01-VALIDATION.md @@ -0,0 +1,77 @@ +--- +phase: 1 +slug: core-pnl-functions +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-03-21 +--- + +# Phase 1 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | go test (testify v1.11.1) | +| **Config file** | none — existing test infrastructure via `testrepo_sqlite.go` | +| **Quick run command** | `go test -v ./internal/service/finance/...` | +| **Full suite command** | `go test -v --cover ./internal/service/finance/...` | +| **Estimated runtime** | ~5 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `go test -v ./internal/service/finance/...` +- **After every plan wave:** Run `go test -v --cover ./internal/service/finance/...` +- **Before `/gsd:verify-work`:** Full suite must be green +- **Max feedback latency:** 10 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|-----------|-------------------|-------------|--------| +| 01-01-01 | 01 | 1 | AST-01 | unit | `go test -run TestAssetType ./internal/service/finance/...` | ❌ W0 | ⬜ pending | +| 01-01-02 | 01 | 1 | PNL-01 | unit | `go test -run TestParams ./internal/service/finance/...` | ❌ W0 | ⬜ pending | +| 01-01-03 | 01 | 1 | QUA-01 | unit | `go test -run TestNew ./internal/service/finance/...` | ❌ W0 | ⬜ pending | +| 01-02-01 | 02 | 2 | DIM-01,PNL-02..08 | integration | `go test -run TestQueryUser ./internal/service/finance/...` | ❌ W0 | ⬜ pending | +| 01-03-01 | 03 | 2 | DIM-02,PNL-02..08 | integration | `go test -run TestQueryActivity ./internal/service/finance/...` | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `internal/service/finance/service_test.go` — test file with SQLite setup via `NewSQLiteRepoForTest()` +- [ ] Test helper functions for seeding orders, inventory, points, coupons test data + +*Existing infrastructure: `testrepo_sqlite.go` provides `NewSQLiteRepoForTest()` — no framework install needed.* + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| GetDbW() absence | QUA-02 | Static check | `grep -r 'GetDbW' internal/service/finance/ \| wc -l` should be 0 | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 10s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending