docs(phase-1): add research and validation strategy

This commit is contained in:
win 2026-03-21 17:17:56 +08:00
parent e0097f50c8
commit e78bbaaf76
2 changed files with 752 additions and 0 deletions

View 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.Timenil=不限),不使用零值作哨兵 | 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)

View 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