docs(phase-1): add research and validation strategy
This commit is contained in:
parent
e0097f50c8
commit
e78bbaaf76
675
.planning/phases/01-core-pnl-functions/01-RESEARCH.md
Normal file
675
.planning/phases/01-core-pnl-functions/01-RESEARCH.md
Normal file
@ -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>
|
||||
## 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)
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## 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 |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
77
.planning/phases/01-core-pnl-functions/01-VALIDATION.md
Normal file
77
.planning/phases/01-core-pnl-functions/01-VALIDATION.md
Normal file
@ -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 `<automated>` 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
|
||||
Loading…
x
Reference in New Issue
Block a user