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