676 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)