diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index ce2854b..8fd6cda 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -23,11 +23,17 @@ Decimal phases appear between their surrounding integers in numeric order.
**Requirements**: PNL-01, PNL-02, PNL-03, PNL-04, PNL-05, PNL-06, PNL-07, PNL-08, DIM-01, DIM-02, DIM-03, DIM-04, RET-01, RET-03, AST-01, QUA-01, QUA-02, QUA-03, QUA-04, QUA-05
**Success Criteria** (what must be TRUE):
1. Calling QueryUserProfitLoss with a list of user IDs returns a ProfitLossResult where Revenue equals actual_amount + discount_amount for non-refunded orders only, and game-pass orders contribute draw_count × activity_price instead of cash revenue
- 2. Calling QueryActivityProfitLoss with an activity ID returns a ProfitLossResult where Revenue is proportionally attributed per order (no double-counting when one order covers multiple activities)
+ 2. Calling QueryActivityProfitLoss with an activity ID returns a ProfitLossResult where Revenue is attributed directly to the order's activity (1:1 per D-01 — no proration subquery)
3. Both functions return an error (not silent zero) when any db.Scan() call fails
4. Passing nil for StartTime/EndTime applies no time filter; passing nil/0 for AssetType returns aggregated totals across all asset types
5. The package contains no call to GetDbW() — all queries route through the injected DbR handle; voided inventory (remark LIKE '%void%' or status=2) and refunded orders (status=3,4) are excluded from cost and revenue respectively
-**Plans**: TBD
+**Plans**: 4 plans
+
+Plans:
+- [ ] 01-01-PLAN.md — Package scaffold: types.go (AssetType enum + param/result structs) + service.go (interface + read-only constructor) + service_test.go (SQLite test infrastructure)
+- [ ] 01-02-PLAN.md — QueryUserProfitLoss: query_user.go with 4 fan-out scans (revenue, inventory cost, points cost, coupon cost) + integration tests
+- [ ] 01-03-PLAN.md — QueryActivityProfitLoss: query_activity.go with 4 fan-out scans attributed to activity dimension + integration tests
+- [ ] 01-04-PLAN.md — Phase 1 verification: full test suite + static checks (no GetDbW, fan-out count, finance functions reused, int64 monetary types)
### Phase 2: Per-Asset-Type Breakdown
**Goal**: The ProfitLossBreakdown slice in every ProfitLossResult contains one entry per relevant asset type (Points, Coupon, ItemCard, Product, Fragment), with correct per-type cost including Fragment synthesis cost sourced from fragment_synthesis_logs
@@ -46,5 +52,5 @@ Phases execute in numeric order: 1 → 2
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
-| 1. Core P&L Functions | 0/TBD | Not started | - |
+| 1. Core P&L Functions | 0/4 | Planning complete | - |
| 2. Per-Asset-Type Breakdown | 0/TBD | Not started | - |
diff --git a/.planning/phases/01-core-pnl-functions/01-01-PLAN.md b/.planning/phases/01-core-pnl-functions/01-01-PLAN.md
new file mode 100644
index 0000000..ba20dad
--- /dev/null
+++ b/.planning/phases/01-core-pnl-functions/01-01-PLAN.md
@@ -0,0 +1,488 @@
+---
+phase: 01-core-pnl-functions
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - internal/service/finance/types.go
+ - internal/service/finance/service.go
+ - internal/service/finance/service_test.go
+autonomous: true
+requirements:
+ - PNL-01
+ - RET-01
+ - RET-03
+ - AST-01
+ - DIM-01
+ - DIM-02
+ - DIM-03
+ - DIM-04
+ - QUA-01
+ - QUA-02
+
+must_haves:
+ truths:
+ - "Package internal/service/finance compiles successfully with the new files"
+ - "AssetType constants All=0, Points=1, Coupon=2, ItemCard=3, Product=4, Fragment=5 are exported"
+ - "UserProfitLossParams and ActivityProfitLossParams structs exist with all optional fields"
+ - "ProfitLossResult struct has int64 TotalRevenue/TotalCost/TotalProfit and float64 ProfitRate"
+ - "Service interface exposes QueryUserProfitLoss and QueryActivityProfitLoss methods"
+ - "New() constructor injects only DbR — no GetDbW() call anywhere in the package"
+ - "service_test.go contains SQLite test setup that compiles and all existing tests pass"
+ artifacts:
+ - path: "internal/service/finance/types.go"
+ provides: "AssetType enum, UserProfitLossParams, ActivityProfitLossParams, ProfitLossDetail, ProfitLossResult"
+ exports:
+ - AssetType
+ - AssetTypeAll
+ - AssetTypePoints
+ - AssetTypeCoupon
+ - AssetTypeItemCard
+ - AssetTypeProduct
+ - AssetTypeFragment
+ - UserProfitLossParams
+ - ActivityProfitLossParams
+ - ProfitLossDetail
+ - ProfitLossResult
+ - path: "internal/service/finance/service.go"
+ provides: "Service interface + New() constructor"
+ exports:
+ - Service
+ - New
+ - path: "internal/service/finance/service_test.go"
+ provides: "Test helper newTestSvc() and seed helpers for orders/inventory/points/coupons"
+ key_links:
+ - from: "internal/service/finance/service.go"
+ to: "internal/repository/mysql/mysql.go"
+ via: "New(l logger.CustomLogger, db mysql.Repo) — calls db.GetDbR() only"
+ pattern: "GetDbR\\(\\)"
+ - from: "internal/service/finance/types.go"
+ to: "internal/service/finance/query_user.go (Plan 02)"
+ via: "UserProfitLossParams consumed by QueryUserProfitLoss"
+ pattern: "UserProfitLossParams"
+---
+
+
+Scaffold the internal/service/finance package with all shared contracts: AssetType enum, parameter structs, result types, the Service interface, and the read-only constructor. Also create the service_test.go file with SQLite test infrastructure that Plans 02 and 03 will extend.
+
+Purpose: Plans 02 and 03 run in parallel and both depend on these type definitions. Creating them first eliminates any ambiguity about field names, types, and the constructor signature.
+
+Output: types.go, service.go, service_test.go — all compiling, all tested.
+
+
+
+@~/.claude/get-shit-done/workflows/execute-plan.md
+@~/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/01-core-pnl-functions/1-CONTEXT.md
+@.planning/phases/01-core-pnl-functions/01-RESEARCH.md
+
+
+
+
+
+From internal/repository/mysql/mysql.go:
+```go
+type Repo interface {
+ i()
+ GetDbR() *gorm.DB
+ GetDbW() *gorm.DB
+ DbRClose() error
+ DbWClose() error
+}
+
+func NewSQLiteRepoForTest() (Repo, error) // in testrepo_sqlite.go
+```
+
+From internal/pkg/logger/logger.go:
+```go
+type CustomLogger interface { /* zap-based */ }
+func NewCustomLogger(w io.Writer, opts ...Option) CustomLogger
+func WithOutputInConsole() Option
+```
+
+From internal/service/finance/profit_metrics.go (EXISTING — must not be redefined):
+```go
+type SpendingBreakdown struct { PaidCoupon, GamePass, Total int64; IsGamePass bool }
+func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, discountAmount int64, remark string, gamePassValue int64) SpendingBreakdown
+func IsGamePassOrder(sourceType int32, orderNo string, actualAmount int64, remark string) bool
+func ComputeGamePassValue(drawCount, activityPrice int64) int64
+func NormalizeMultiplierX1000(multiplierX1000 int64) int64
+func ComputePrizeCostWithMultiplier(baseCost, multiplierX1000 int64) int64
+func ComputeProfit(spending, prizeCost int64) (int64, float64)
+```
+
+From internal/repository/mysql/model/user_inventory.gen.go:
+```go
+const TableNameUserInventory = "user_inventory"
+// Fields used: user_id, activity_id, order_id, value_cents, status, remark, reward_id, product_id
+```
+
+From internal/repository/mysql/model/user_points_ledger.gen.go:
+```go
+const TableNameUserPointsLedger = "user_points_ledger"
+// Fields used: user_id, action, points, ref_table, ref_id
+```
+
+From internal/repository/mysql/model/user_coupon_ledger.gen.go:
+```go
+const TableNameUserCouponLedger = "user_coupon_ledger"
+// Fields used: user_id, change_amount, order_id, action
+```
+
+
+
+
+
+ Task 1: Create types.go — AssetType enum and all struct contracts
+
+ - internal/service/finance/profit_metrics.go (verify SpendingBreakdown is not redefined here)
+ - internal/repository/mysql/model/user_inventory.gen.go (confirm value_cents field name)
+ - .planning/phases/01-core-pnl-functions/1-CONTEXT.md (locked decisions D-04 through D-11)
+
+ internal/service/finance/types.go
+
+ - AssetTypeAll = 0, AssetTypePoints = 1, AssetTypeCoupon = 2, AssetTypeItemCard = 3, AssetTypeProduct = 4, AssetTypeFragment = 5
+ - UserProfitLossParams has: UserIDs []int64, AssetType AssetType, StartTime *time.Time, EndTime *time.Time
+ - ActivityProfitLossParams has: ActivityIDs []int64, AssetType AssetType, StartTime *time.Time, EndTime *time.Time
+ - ProfitLossDetail has: UserID int64, ActivityID int64, Revenue int64, Cost int64, Profit int64, ProfitRate float64
+ - ProfitLossResult has: TotalRevenue int64, TotalCost int64, TotalProfit int64, ProfitRate float64, Details []ProfitLossDetail, Breakdown []interface{}
+ - All monetary fields are int64 (fen); only ProfitRate and ProfitLossDetail.ProfitRate use float64
+ - Breakdown is []interface{} initialized as empty slice (Phase 2 placeholder per CONTEXT.md deferred section)
+
+
+Create `internal/service/finance/types.go` with package `finance`. Import only `"time"`.
+
+Define the AssetType and constants block:
+```go
+type AssetType int
+
+const (
+ AssetTypeAll AssetType = 0 // zero value = all types (DIM-04)
+ AssetTypePoints AssetType = 1
+ AssetTypeCoupon AssetType = 2
+ AssetTypeItemCard AssetType = 3
+ AssetTypeProduct AssetType = 4
+ AssetTypeFragment AssetType = 5
+)
+```
+
+Define param structs (per D-04 — two independent structs, not shared):
+```go
+// UserProfitLossParams — all fields optional (D-07)
+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)
+}
+
+// ActivityProfitLossParams — all fields optional (D-07)
+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)
+}
+```
+
+Define result structs (per D-05, D-06, RET-01, RET-03):
+```go
+// ProfitLossDetail — per-user or per-activity row (D-06)
+type ProfitLossDetail struct {
+ UserID int64 // populated for user dimension queries
+ ActivityID int64 // populated for activity dimension queries
+ Revenue int64 // fen (RET-03: int64 only, no float64 for monetary)
+ Cost int64 // fen
+ Profit int64 // fen
+ ProfitRate float64 // ratio; only float64 field for monetary concept
+}
+
+// ProfitLossResult — aggregated P&L result (RET-01)
+type ProfitLossResult struct {
+ TotalRevenue int64 // fen
+ TotalCost int64 // fen
+ TotalProfit int64 // fen
+ ProfitRate float64 // ratio
+ Details []ProfitLossDetail // per-user or per-activity breakdowns (D-06)
+ Breakdown []interface{} // Phase 2: per-asset-type breakdown (empty for Phase 1)
+}
+```
+
+
+ go build ./internal/service/finance/ 2>&1 | grep -v "^$" || echo "BUILD OK"
+
+
+ - internal/service/finance/types.go exists
+ - File contains `type AssetType int`
+ - File contains `AssetTypeAll AssetType = 0`
+ - File contains `AssetTypeFragment AssetType = 5`
+ - File contains `type UserProfitLossParams struct`
+ - File contains `type ActivityProfitLossParams struct`
+ - File contains `UserIDs []int64` inside UserProfitLossParams
+ - File contains `ActivityIDs []int64` inside ActivityProfitLossParams
+ - File contains `StartTime *time.Time` and `EndTime *time.Time` (pointer, not value)
+ - File contains `type ProfitLossResult struct`
+ - File contains `TotalRevenue int64`
+ - File contains `TotalCost int64`
+ - File contains `TotalProfit int64`
+ - File contains `ProfitRate float64`
+ - File contains `Details []ProfitLossDetail`
+ - File contains `Breakdown []interface{}`
+ - File contains `type ProfitLossDetail struct`
+ - File contains `Revenue int64` (not float64)
+ - File contains `Cost int64` (not float64)
+ - `go build ./internal/service/finance/` exits 0
+
+ types.go exists in internal/service/finance/, all types exported with correct field names and types, package builds without errors.
+
+
+
+ Task 2: Create service.go — Service interface and read-only constructor
+
+ - internal/service/finance/types.go (just created — verify param/result type names)
+ - internal/service/user/user.go (lines 93-102 — constructor pattern to replicate)
+ - internal/repository/mysql/mysql.go (Repo interface — confirm GetDbR() signature)
+ - .planning/phases/01-core-pnl-functions/1-CONTEXT.md (QUA-02: no GetDbW() in this package)
+
+ internal/service/finance/service.go
+
+ - Service interface declares exactly two methods: QueryUserProfitLoss and QueryActivityProfitLoss
+ - QueryUserProfitLoss signature: (ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error)
+ - QueryActivityProfitLoss signature: (ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error)
+ - service struct has logger field and dbR *gorm.DB — NO writeDB or GetDbW() call
+ - New() calls db.GetDbR() to populate dbR; no reference to GetDbW() anywhere in file
+ - Stub implementations return (nil, nil) — they will be replaced in Plans 02 and 03
+
+
+Create `internal/service/finance/service.go` with package `finance`.
+
+Imports:
+```go
+import (
+ "context"
+
+ "bindbox-game/internal/pkg/logger"
+ "bindbox-game/internal/repository/mysql"
+ "gorm.io/gorm"
+)
+```
+
+Define Service interface and struct:
+```go
+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 — QUA-02: no writes in this package
+}
+
+func New(l logger.CustomLogger, db mysql.Repo) Service {
+ return &service{
+ logger: l,
+ dbR: db.GetDbR(),
+ }
+}
+```
+
+Add stub method bodies (Plans 02 and 03 will replace these):
+```go
+func (s *service) QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) {
+ return nil, nil
+}
+
+func (s *service) QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error) {
+ return nil, nil
+}
+```
+
+CRITICAL: Do NOT write `db.GetDbW()` or `GetDbW` anywhere in this file or any other file in the package.
+
+
+ go build ./internal/service/finance/ && grep -r "GetDbW" ./internal/service/finance/ | wc -l | xargs test 0 -eq && echo "QUA-02 OK: no GetDbW in package"
+
+
+ - internal/service/finance/service.go exists
+ - File contains `type Service interface`
+ - File contains `QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error)`
+ - File contains `QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error)`
+ - File contains `type service struct`
+ - File contains `dbR *gorm.DB`
+ - File does NOT contain `GetDbW`
+ - File contains `db.GetDbR()`
+ - File contains `func New(l logger.CustomLogger, db mysql.Repo) Service`
+ - `go build ./internal/service/finance/` exits 0
+ - `grep -r "GetDbW" ./internal/service/finance/` returns empty output (zero matches)
+
+ service.go compiles, Service interface declared with both method signatures, constructor injects GetDbR() only, no GetDbW() anywhere in the finance package.
+
+
+
+ Task 3: Create service_test.go — SQLite test infrastructure and contract tests
+
+ - internal/service/finance/service.go (just created — verify New() signature)
+ - internal/service/finance/types.go (verify AssetType constant values and struct field names)
+ - internal/repository/mysql/testrepo_sqlite.go (NewSQLiteRepoForTest() — verify signature)
+ - internal/service/finance/profit_metrics_test.go (existing test style to replicate)
+ - .planning/phases/01-core-pnl-functions/01-RESEARCH.md (Pitfall 6: SQLite compat — CAST AS INTEGER not SIGNED)
+
+ internal/service/finance/service_test.go
+
+ - newTestSvc() helper creates SQLiteRepo and returns (Service, *gorm.DB, error)
+ - seedOrder() helper inserts a model.Orders row into the test DB
+ - seedInventory() helper inserts a model.UserInventory row
+ - seedPointsLedger() helper inserts a model.UserPointsLedger row
+ - seedCouponLedger() helper inserts a model.UserCouponLedger row
+ - TestAssetTypeConstants verifies All=0, Points=1, Coupon=2, ItemCard=3, Product=4, Fragment=5
+ - TestNew_ReturnsService verifies New() returns a non-nil Service
+ - TestQueryUserProfitLoss_EmptyParams_ReturnsNoError verifies stub returns (nil, nil) — will be updated in Plan 02
+ - TestQueryActivityProfitLoss_EmptyParams_ReturnsNoError same for activity function
+ - AutoMigrate runs for Orders, UserInventory, UserPointsLedger, UserCouponLedger tables
+
+
+Create `internal/service/finance/service_test.go` with package `finance`.
+
+Imports needed:
+```go
+import (
+ "context"
+ "testing"
+
+ "bindbox-game/internal/pkg/logger"
+ "bindbox-game/internal/repository/mysql"
+ "bindbox-game/internal/repository/mysql/model"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+```
+
+Test helper — newTestSvc creates an in-memory SQLite repo, auto-migrates tables, returns (Service, *gorm.DB):
+```go
+func newTestSvc(t *testing.T) (Service, *gorm.DB) {
+ t.Helper()
+ repo, err := mysql.NewSQLiteRepoForTest()
+ require.NoError(t, err)
+ db := repo.GetDbR()
+ err = db.AutoMigrate(
+ &model.Orders{},
+ &model.UserInventory{},
+ &model.UserPointsLedger{},
+ &model.UserCouponLedger{},
+ )
+ require.NoError(t, err)
+ svc := New(logger.NewCustomLogger(nil, logger.WithOutputInConsole()), repo)
+ return svc, db
+}
+```
+
+Seed helpers (minimal fields — add more fields in Plans 02/03 tests as needed):
+```go
+func seedOrder(t *testing.T, db *gorm.DB, o model.Orders) {
+ t.Helper()
+ require.NoError(t, db.Create(&o).Error)
+}
+
+func seedInventory(t *testing.T, db *gorm.DB, inv model.UserInventory) {
+ t.Helper()
+ require.NoError(t, db.Create(&inv).Error)
+}
+
+func seedPointsLedger(t *testing.T, db *gorm.DB, row model.UserPointsLedger) {
+ t.Helper()
+ require.NoError(t, db.Create(&row).Error)
+}
+
+func seedCouponLedger(t *testing.T, db *gorm.DB, row model.UserCouponLedger) {
+ t.Helper()
+ require.NoError(t, db.Create(&row).Error)
+}
+```
+
+Tests:
+```go
+func TestAssetTypeConstants(t *testing.T) {
+ require.Equal(t, AssetType(0), AssetTypeAll)
+ require.Equal(t, AssetType(1), AssetTypePoints)
+ require.Equal(t, AssetType(2), AssetTypeCoupon)
+ require.Equal(t, AssetType(3), AssetTypeItemCard)
+ require.Equal(t, AssetType(4), AssetTypeProduct)
+ require.Equal(t, AssetType(5), AssetTypeFragment)
+}
+
+func TestNew_ReturnsService(t *testing.T) {
+ svc, _ := newTestSvc(t)
+ require.NotNil(t, svc)
+}
+
+func TestQueryUserProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) {
+ svc, _ := newTestSvc(t)
+ result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{})
+ require.NoError(t, err)
+ _ = result // stub returns nil — Plan 02 will make this return real data
+}
+
+func TestQueryActivityProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) {
+ svc, _ := newTestSvc(t)
+ result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{})
+ require.NoError(t, err)
+ _ = result // stub returns nil — Plan 03 will make this return real data
+}
+```
+
+NOTE on SQLite compatibility (Pitfall 6 from RESEARCH.md):
+- Do NOT use CAST(... AS SIGNED) in test SQL — SQLite requires CAST(... AS INTEGER)
+- Do NOT use GREATEST() in SQL for tests — apply multiplier logic in Go instead
+- Tests here are unit/compile-time tests only; integration tests added in Plans 02 and 03
+
+
+ go test -v -run "TestAssetType|TestNew|TestQuery.*EmptyParams" ./internal/service/finance/
+
+
+ - internal/service/finance/service_test.go exists
+ - File contains `func newTestSvc(t *testing.T) (Service, *gorm.DB)`
+ - File contains `func seedOrder(`
+ - File contains `func seedInventory(`
+ - File contains `func seedPointsLedger(`
+ - File contains `func seedCouponLedger(`
+ - File contains `func TestAssetTypeConstants(`
+ - File contains `mysql.NewSQLiteRepoForTest()`
+ - File contains `db.AutoMigrate`
+ - `go test -v -run "TestAssetType|TestNew|TestQuery.*EmptyParams" ./internal/service/finance/` exits 0
+ - All 4 tests pass: TestAssetTypeConstants, TestNew_ReturnsService, TestQueryUserProfitLoss_EmptyParams_ReturnsNoError, TestQueryActivityProfitLoss_EmptyParams_ReturnsNoError
+ - `go test -v ./internal/service/finance/` exits 0 (all existing profit_metrics tests still pass)
+
+ service_test.go compiles and all tests pass including the existing profit_metrics tests. The newTestSvc and seed helpers are ready for Plans 02 and 03 to extend.
+
+
+
+
+
+After all tasks complete:
+
+1. Package compiles: `go build ./internal/service/finance/` exits 0
+2. All tests green: `go test -v ./internal/service/finance/` — must show PASS for all tests including existing profit_metrics tests
+3. No write DB leak: `grep -r "GetDbW" ./internal/service/finance/` returns 0 matches
+4. AssetType values correct: `grep -A8 "AssetTypeAll" internal/service/finance/types.go` shows All=0 through Fragment=5
+5. Pointer time fields: `grep "StartTime\|EndTime" internal/service/finance/types.go | grep "\*time.Time"` returns 2 matches
+6. int64 monetary fields only: `grep "Revenue\|Cost\|Profit\b" internal/service/finance/types.go | grep "float64"` returns 0 matches (ProfitRate is the only float64)
+
+
+
+- internal/service/finance/types.go: 6 AssetType constants + 2 param structs + 2 result structs, all exported
+- internal/service/finance/service.go: Service interface with 2 methods, read-only constructor, zero GetDbW() references
+- internal/service/finance/service_test.go: SQLite setup helper + 4 seed helpers + 4 tests all passing
+- `go test -v ./internal/service/finance/` exits 0 with PASS for all tests
+- Full build clean: `go build ./...` exits 0
+
+
+
diff --git a/.planning/phases/01-core-pnl-functions/01-02-PLAN.md b/.planning/phases/01-core-pnl-functions/01-02-PLAN.md
new file mode 100644
index 0000000..1a4518a
--- /dev/null
+++ b/.planning/phases/01-core-pnl-functions/01-02-PLAN.md
@@ -0,0 +1,622 @@
+---
+phase: 01-core-pnl-functions
+plan: 02
+type: execute
+wave: 2
+depends_on:
+ - 01-01
+files_modified:
+ - internal/service/finance/query_user.go
+ - internal/service/finance/service.go
+ - internal/service/finance/service_test.go
+autonomous: true
+requirements:
+ - PNL-02
+ - PNL-03
+ - PNL-04
+ - PNL-05
+ - PNL-06
+ - PNL-07
+ - PNL-08
+ - DIM-01
+ - DIM-03
+ - DIM-04
+ - QUA-03
+ - QUA-04
+ - QUA-05
+
+must_haves:
+ truths:
+ - "QueryUserProfitLoss with a paid cash order returns Revenue = actual_amount + discount_amount"
+ - "QueryUserProfitLoss excludes refunded orders (status=3 or status=4) from Revenue"
+ - "QueryUserProfitLoss with a game-pass order returns Revenue = draw_count × activity_price (not actual_amount)"
+ - "QueryUserProfitLoss excludes voided inventory (remark LIKE '%void%' or status=2) from Cost"
+ - "QueryUserProfitLoss includes legacy inventory with order_id=0 in Cost (not filtered out)"
+ - "QueryUserProfitLoss with empty UserIDs returns aggregated result for all users (not empty)"
+ - "QueryUserProfitLoss returns error (not nil) when a Scan() call fails"
+ - "ProfitLossResult.TotalProfit = TotalRevenue - TotalCost; ProfitRate computed by ComputeProfit()"
+ artifacts:
+ - path: "internal/service/finance/query_user.go"
+ provides: "QueryUserProfitLoss implementation with fan-out scans"
+ exports: []
+ - path: "internal/service/finance/service.go"
+ provides: "Updated QueryUserProfitLoss method body (replaces stub from Plan 01)"
+ - path: "internal/service/finance/service_test.go"
+ provides: "Integration tests: refund exclusion, game-pass revenue, void exclusion, legacy order_id=0"
+ key_links:
+ - from: "internal/service/finance/query_user.go"
+ to: "internal/repository/mysql/model (Orders, UserInventory, UserPointsLedger, UserCouponLedger)"
+ via: "s.dbR.Table(model.TableNameOrders).Select(...).Scan()"
+ pattern: "Scan\\(&"
+ - from: "internal/service/finance/query_user.go"
+ to: "finance.ClassifyOrderSpending / IsGamePassOrder / ComputeProfit"
+ via: "Go-layer classification of per-order rows after scan"
+ pattern: "ClassifyOrderSpending|ComputeProfit"
+ - from: "internal/service/finance/query_user.go"
+ to: "system_configs table"
+ via: "getPointsExchangeRate() reads 'points.exchange_rate' key"
+ pattern: "points\\.exchange_rate"
+---
+
+
+Implement QueryUserProfitLoss in a new query_user.go file using the fan-out + in-memory merge pattern. Four independent Scan() calls gather revenue, inventory cost, points cost, and coupon cost; results are merged in Go via map[int64]*ProfitLossDetail. The service.go stub from Plan 01 is replaced with a real dispatch call.
+
+Purpose: Deliver the user-dimension P&L function with all PNL-02 through PNL-08 requirements satisfied and all DIM-01/03/04 parameter handling in place.
+
+Output: query_user.go (implementation), service.go (updated), service_test.go (extended with integration tests).
+
+
+
+@~/.claude/get-shit-done/workflows/execute-plan.md
+@~/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/01-core-pnl-functions/1-CONTEXT.md
+@.planning/phases/01-core-pnl-functions/01-RESEARCH.md
+@.planning/phases/01-core-pnl-functions/01-01-SUMMARY.md
+
+
+
+
+
+From internal/service/finance/types.go (created in Plan 01):
+```go
+type AssetType int
+const ( AssetTypeAll=0; AssetTypePoints=1; AssetTypeCoupon=2; AssetTypeItemCard=3; AssetTypeProduct=4; AssetTypeFragment=5 )
+
+type UserProfitLossParams struct {
+ UserIDs []int64
+ AssetType AssetType
+ StartTime *time.Time
+ EndTime *time.Time
+}
+
+type ProfitLossDetail struct {
+ UserID, ActivityID int64
+ Revenue, Cost, Profit int64
+ ProfitRate float64
+}
+
+type ProfitLossResult struct {
+ TotalRevenue, TotalCost, TotalProfit int64
+ ProfitRate float64
+ Details []ProfitLossDetail
+ Breakdown []interface{}
+}
+```
+
+From internal/service/finance/service.go (created in Plan 01):
+```go
+type service struct { logger logger.CustomLogger; dbR *gorm.DB }
+// QueryUserProfitLoss stub — REPLACE with real dispatch: return s.queryUser(ctx, params)
+```
+
+From internal/service/finance/profit_metrics.go (existing — MUST reuse, do not reimplement):
+```go
+func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, discountAmount int64, remark string, gamePassValue int64) SpendingBreakdown
+func IsGamePassOrder(sourceType int32, orderNo string, actualAmount int64, remark string) bool
+func ComputeGamePassValue(drawCount, activityPrice int64) int64
+func ComputePrizeCostWithMultiplier(baseCost, multiplierX1000 int64) int64
+func ComputeProfit(spending, prizeCost int64) (int64, float64)
+```
+
+From internal/repository/mysql/model/ (table name constants):
+```go
+model.TableNameOrders = "orders"
+model.TableNameUserInventory = "user_inventory"
+model.TableNameUserPointsLedger = "user_points_ledger"
+model.TableNameUserCouponLedger = "user_coupon_ledger"
+```
+
+Orders table fields used: id, user_id, status, source_type, order_no, actual_amount, discount_amount, remark, draw_count, created_at
+UserInventory fields used: user_id, activity_id, order_id, value_cents, status, remark, reward_id (for item-card join)
+UserPointsLedger fields used: user_id, action, points, created_at
+UserCouponLedger fields used: user_id, change_amount, order_id, created_at
+
+
+
+
+
+ Task 1: Create query_user.go — QueryUserProfitLoss fan-out implementation
+
+ - internal/service/finance/service.go (verify service struct and stub method signature)
+ - internal/service/finance/types.go (verify UserProfitLossParams and ProfitLossResult fields)
+ - internal/service/finance/profit_metrics.go (verify ClassifyOrderSpending, ComputeProfit signatures)
+ - internal/api/admin/dashboard_activity.go (lines 225-300, fan-out pattern and exact WHERE conditions)
+ - .planning/phases/01-core-pnl-functions/01-RESEARCH.md (Pitfall 1: CAST AS SIGNED; Pitfall 2: empty slice; Pitfall 3: game-pass double count; Pitfall 4: refund+inventory; Pitfall 5: scan error; Pitfall 6: SQLite compat)
+
+ internal/service/finance/query_user.go
+
+ - Revenue scan: SELECT user_id + raw order fields (source_type, order_no, actual_amount, discount_amount, remark) for orders WHERE status=2; classify per-row in Go using ClassifyOrderSpending(); sum by user_id
+ - Game-pass needs draw_count and activity price; join orders → activity_draw_logs → activity_issues → activities to get activities.price_draw per order
+ - Cost scan (inventory): SELECT user_id, SUM(value_cents) grouped by user_id; WHERE status IN (1,3) AND remark NOT LIKE '%void%' AND (orders.status=2 OR order_id=0 OR order_id IS NULL); LEFT JOIN orders ON orders.id = user_inventory.order_id; for multiplier: LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id; apply ComputePrizeCostWithMultiplier(value_cents, multiplier) in Go (not SQL) for SQLite compat
+ - Cost scan (points): SELECT user_id, SUM(points) WHERE action='order_deduct' AND points < 0; convert via getPointsExchangeRate() and points.PointsToCents()
+ - Cost scan (coupons): SELECT user_id, SUM(ABS(change_amount)) WHERE change_amount < 0; JOIN orders ON orders.id = user_coupon_ledger.order_id WHERE orders.status=2
+ - Every Scan() must check .Error and return fmt.Errorf("QueryUserProfitLoss %s scan: %w", step, err)
+ - Empty UserIDs: do NOT add WHERE user_id IN clause (all users)
+ - Time filters: add WHERE created_at >= *StartTime only if StartTime != nil
+ - Merge all scans in Go via map[int64]*ProfitLossDetail
+ - Final aggregation: sum all Details into TotalRevenue/TotalCost; call ComputeProfit for TotalProfit+ProfitRate
+ - Return &ProfitLossResult{..., Breakdown: []interface{}{}} with empty Breakdown slice
+
+
+Create `internal/service/finance/query_user.go` with package `finance`.
+
+Imports:
+```go
+import (
+ "context"
+ "fmt"
+
+ "bindbox-game/internal/pkg/points"
+ "bindbox-game/internal/repository/mysql/model"
+ "gorm.io/gorm"
+)
+```
+
+Private method `(s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error)`.
+
+**Step 1: Revenue scan** — scan raw order fields per user, classify in Go.
+
+Scan struct:
+```go
+type userRevenueRow struct {
+ UserID int64
+ SourceType int32
+ OrderNo string
+ ActualAmount int64
+ DiscountAmount int64
+ Remark string
+ DrawCount int64
+ ActivityPrice int64 // from activities.price_draw via JOIN
+}
+```
+
+Query (scan per-order, not pre-aggregated — needed for per-row classification):
+```go
+var revenueRows []userRevenueRow
+q := s.dbR.WithContext(ctx).
+ Table(model.TableNameOrders).
+ Select(`orders.user_id, orders.source_type, orders.order_no,
+ orders.actual_amount, orders.discount_amount, orders.remark,
+ COUNT(activity_draw_logs.id) as draw_count,
+ COALESCE(MAX(activities.price_draw), 0) as activity_price`).
+ Joins(`LEFT JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id`).
+ Joins(`LEFT JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id`).
+ Joins(`LEFT JOIN activities ON activities.id = activity_issues.activity_id`).
+ Where("orders.status = ?", 2).
+ Group("orders.id, orders.user_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.remark")
+if len(params.UserIDs) > 0 {
+ q = q.Where("orders.user_id IN ?", params.UserIDs)
+}
+if params.StartTime != nil {
+ q = q.Where("orders.created_at >= ?", *params.StartTime)
+}
+if params.EndTime != nil {
+ q = q.Where("orders.created_at <= ?", *params.EndTime)
+}
+if err := q.Scan(&revenueRows).Error; err != nil {
+ return nil, fmt.Errorf("QueryUserProfitLoss revenue scan: %w", err)
+}
+```
+
+Merge revenue into resultMap — classify per row using Go functions:
+```go
+resultMap := make(map[int64]*ProfitLossDetail)
+for _, r := range revenueRows {
+ gpValue := ComputeGamePassValue(r.DrawCount, r.ActivityPrice)
+ bd := ClassifyOrderSpending(r.SourceType, r.OrderNo, r.ActualAmount, r.DiscountAmount, r.Remark, gpValue)
+ if _, ok := resultMap[r.UserID]; !ok {
+ resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
+ }
+ resultMap[r.UserID].Revenue += bd.Total
+}
+```
+
+**Step 2: Inventory cost scan** — scan raw value_cents + multiplier per inventory row, apply ComputePrizeCostWithMultiplier in Go.
+
+Scan struct:
+```go
+type userInventoryRow struct {
+ UserID int64
+ ValueCents int64
+ MultiplierX1000 int64
+}
+```
+
+Query:
+```go
+var inventoryRows []userInventoryRow
+iq := s.dbR.WithContext(ctx).
+ Table(model.TableNameUserInventory).
+ Select(`user_inventory.user_id,
+ user_inventory.value_cents,
+ COALESCE(system_item_cards.reward_multiplier_x1000, 1000) as multiplier_x1000`).
+ Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
+ Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
+ Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
+ 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)
+if len(params.UserIDs) > 0 {
+ iq = iq.Where("user_inventory.user_id IN ?", params.UserIDs)
+}
+if params.StartTime != nil {
+ iq = iq.Where("user_inventory.created_at >= ?", *params.StartTime)
+}
+if params.EndTime != nil {
+ iq = iq.Where("user_inventory.created_at <= ?", *params.EndTime)
+}
+var inventoryRows []userInventoryRow
+if err := iq.Scan(&inventoryRows).Error; err != nil {
+ return nil, fmt.Errorf("QueryUserProfitLoss inventory cost scan: %w", err)
+}
+for _, r := range inventoryRows {
+ cost := ComputePrizeCostWithMultiplier(r.ValueCents, r.MultiplierX1000)
+ if _, ok := resultMap[r.UserID]; !ok {
+ resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
+ }
+ resultMap[r.UserID].Cost += cost
+}
+```
+
+**Step 3: Points cost scan** — read points deductions and convert to cents.
+
+```go
+type userPointsRow struct {
+ UserID int64
+ TotalPoints int64 // SUM of negative points = total deducted (positive value after ABS)
+}
+var pointsRows []userPointsRow
+pq := s.dbR.WithContext(ctx).
+ Table(model.TableNameUserPointsLedger).
+ Select("user_id, SUM(-points) as total_points"). // points is negative for deductions
+ Where("action = ?", "order_deduct").
+ Where("points < ?", 0)
+if len(params.UserIDs) > 0 {
+ pq = pq.Where("user_id IN ?", params.UserIDs)
+}
+if params.StartTime != nil {
+ pq = pq.Where("created_at >= ?", *params.StartTime)
+}
+if params.EndTime != nil {
+ pq = pq.Where("created_at <= ?", *params.EndTime)
+}
+pq = pq.Group("user_id")
+if err := pq.Scan(&pointsRows).Error; err != nil {
+ return nil, fmt.Errorf("QueryUserProfitLoss points cost scan: %w", err)
+}
+rate := s.getPointsExchangeRate(ctx)
+for _, r := range pointsRows {
+ costCents := points.PointsToCents(r.TotalPoints, float64(rate))
+ if _, ok := resultMap[r.UserID]; !ok {
+ resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
+ }
+ resultMap[r.UserID].Cost += costCents
+}
+```
+
+**Step 4: Coupon cost scan** — sum coupon deductions from paid orders.
+
+```go
+type userCouponRow struct {
+ UserID int64
+ TotalCost int64 // SUM(ABS(change_amount)) for deductions
+}
+var couponRows []userCouponRow
+cq := s.dbR.WithContext(ctx).
+ Table(model.TableNameUserCouponLedger).
+ Select("user_coupon_ledger.user_id, SUM(-user_coupon_ledger.change_amount) as total_cost").
+ Joins("LEFT JOIN orders ON orders.id = user_coupon_ledger.order_id").
+ Where("user_coupon_ledger.change_amount < ?", 0).
+ Where("orders.status = ?", 2)
+if len(params.UserIDs) > 0 {
+ cq = cq.Where("user_coupon_ledger.user_id IN ?", params.UserIDs)
+}
+if params.StartTime != nil {
+ cq = cq.Where("user_coupon_ledger.created_at >= ?", *params.StartTime)
+}
+if params.EndTime != nil {
+ cq = cq.Where("user_coupon_ledger.created_at <= ?", *params.EndTime)
+}
+cq = cq.Group("user_coupon_ledger.user_id")
+if err := cq.Scan(&couponRows).Error; err != nil {
+ return nil, fmt.Errorf("QueryUserProfitLoss coupon cost scan: %w", err)
+}
+for _, r := range couponRows {
+ if _, ok := resultMap[r.UserID]; !ok {
+ resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
+ }
+ resultMap[r.UserID].Cost += r.TotalCost
+}
+```
+
+**Step 5: Apply ComputeProfit per detail and aggregate totals.**
+
+```go
+details := make([]ProfitLossDetail, 0, len(resultMap))
+var totalRevenue, totalCost int64
+for _, d := range resultMap {
+ d.Profit, d.ProfitRate = ComputeProfit(d.Revenue, d.Cost)
+ totalRevenue += d.Revenue
+ totalCost += d.Cost
+ details = append(details, *d)
+}
+totalProfit, profitRate := ComputeProfit(totalRevenue, totalCost)
+return &ProfitLossResult{
+ TotalRevenue: totalRevenue,
+ TotalCost: totalCost,
+ TotalProfit: totalProfit,
+ ProfitRate: profitRate,
+ Details: details,
+ Breakdown: []interface{}{},
+}, nil
+```
+
+**Private helper — getPointsExchangeRate** (reads system_configs, safe default=1):
+```go
+func (s *service) getPointsExchangeRate(ctx context.Context) int64 {
+ var cfg struct { ConfigValue string }
+ if err := s.dbR.WithContext(ctx).
+ Table("system_configs").
+ Select("config_value").
+ 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
+}
+```
+
+**Update service.go stub** — replace the stub QueryUserProfitLoss body:
+```go
+func (s *service) QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) {
+ return s.queryUser(ctx, params)
+}
+```
+
+CRITICAL rules (from RESEARCH.md anti-patterns):
+- NEVER call GetDbW() — only s.dbR
+- NEVER skip .Error check on any Scan()
+- NEVER add WHERE user_id IN when params.UserIDs is empty
+- NEVER use CAST(AS SIGNED) in SQL — apply multiplier in Go via ComputePrizeCostWithMultiplier
+- NEVER re-implement IsGamePassOrder logic in SQL — use Go function
+- NEVER use COALESCE fallback chain for value_cents — D-09: value_cents is single source of truth
+
+
+ go build ./internal/service/finance/ && go test -v -run "TestQueryUser" ./internal/service/finance/
+
+
+ - internal/service/finance/query_user.go exists
+ - File contains `func (s *service) queryUser(`
+ - File contains `func (s *service) getPointsExchangeRate(`
+ - File contains at least 4 `Scan(&` calls (one per data source)
+ - File contains `ClassifyOrderSpending(` (reusing existing function, not reimplementing)
+ - File contains `ComputeProfit(` call for totals
+ - File contains `ComputePrizeCostWithMultiplier(` in Go layer (not inside SQL string)
+ - File contains `points.PointsToCents(`
+ - File does NOT contain `GetDbW`
+ - File does NOT contain `CAST(` (multiplier applied in Go, not SQL)
+ - File does NOT contain `COALESCE(NULLIF(user_inventory.value_cents` (no fallback chain)
+ - For each `Scan(` call, there is a corresponding `if err :=` error check
+ - service.go QueryUserProfitLoss body contains `return s.queryUser(ctx, params)`
+ - `go build ./internal/service/finance/` exits 0
+
+ query_user.go implements QueryUserProfitLoss with 4 fan-out scans, all errors propagated, game-pass classified in Go, multiplier applied in Go, service.go dispatches to it.
+
+
+
+ Task 2: Add QueryUserProfitLoss integration tests to service_test.go
+
+ - internal/service/finance/service_test.go (existing helpers from Plan 01 — newTestSvc, seedOrder etc.)
+ - internal/service/finance/query_user.go (just implemented — verify scan logic to test correctly)
+ - internal/repository/mysql/model/orders.gen.go (Orders struct field names for seeding)
+ - internal/repository/mysql/model/user_inventory.gen.go (UserInventory struct field names)
+ - .planning/phases/01-core-pnl-functions/01-RESEARCH.md (Pitfall 6: SQLite compat — no CAST AS SIGNED in test SQL; Pitfall 1: CAST issue only affects MySQL not SQLite int scan)
+
+ internal/service/finance/service_test.go
+
+ - TestQueryUserProfitLoss_CashOrder: seed one paid order (status=2, non-game-pass), assert Revenue = actual_amount + discount_amount
+ - TestQueryUserProfitLoss_RefundedOrderExcluded: seed one refunded order (status=4), assert Revenue=0
+ - TestQueryUserProfitLoss_GamePassOrder: seed one game-pass order (source_type=4, actual_amount=0), seed activity with price_draw, assert Revenue = draw_count × price_draw
+ - TestQueryUserProfitLoss_VoidedInventoryExcluded: seed inventory with status=2, assert Cost=0
+ - TestQueryUserProfitLoss_RemarkVoidExcluded: seed inventory with remark='void_test', assert Cost=0
+ - TestQueryUserProfitLoss_LegacyZeroOrderID: seed inventory with order_id=0, assert it IS included in Cost (not excluded)
+ - TestQueryUserProfitLoss_AllUsers: seed 2 users, call with empty UserIDs, assert both appear in Details
+ - TestQueryUserProfitLoss_FilterByUserID: seed 2 users, call with one UserID, assert only that user in Details
+ - TestQueryUserProfitLoss_ResultShape: assert returned ProfitLossResult has non-nil Details and non-nil Breakdown fields
+ - TestQueryUserProfitLoss_ProfitCalculation: seed order + inventory, assert TotalProfit = TotalRevenue - TotalCost
+
+
+Append integration tests to `internal/service/finance/service_test.go`.
+
+First check what model fields are available by reading model/orders.gen.go. The Orders model has fields: ID, UserID, Status, SourceType, OrderNo, ActualAmount, DiscountAmount, Remark, DrawCount, CreatedAt, etc.
+
+NOTE: The AutoMigrate in newTestSvc must cover all tables used in query_user.go. Update newTestSvc if it doesn't already include system_configs, activities, activity_draw_logs, activity_issues, user_item_cards, system_item_cards tables. If AutoMigrate fails for a table (because model doesn't exist), use db.Exec("CREATE TABLE IF NOT EXISTS ...") for simple tables.
+
+For game-pass test, seed the activity and activity draw log rows so the JOIN in queryUser can find the activity price. Alternatively, use source_type=4 + order_no LIKE 'GP%' and a direct activities.price_draw lookup. Keep test setup minimal.
+
+```go
+func TestQueryUserProfitLoss_CashOrder(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedOrder(t, db, model.Orders{
+ ID: 1, UserID: 101, Status: 2,
+ SourceType: 2, OrderNo: "O20260321001",
+ ActualAmount: 800, DiscountAmount: 200,
+ })
+ result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{101}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, int64(1000), result.TotalRevenue, "cash revenue = actual + discount")
+}
+
+func TestQueryUserProfitLoss_RefundedOrderExcluded(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedOrder(t, db, model.Orders{
+ ID: 2, UserID: 102, Status: 4, // refunded
+ SourceType: 2, OrderNo: "O20260321002",
+ ActualAmount: 1000, DiscountAmount: 0,
+ })
+ result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{102}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, int64(0), result.TotalRevenue, "refunded order must not contribute revenue")
+}
+
+func TestQueryUserProfitLoss_VoidedInventoryExcluded(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedInventory(t, db, model.UserInventory{
+ ID: 1, UserID: 103, Status: 2, // voided status
+ ValueCents: 5000, OrderID: 0,
+ })
+ result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{103}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, int64(0), result.TotalCost, "voided inventory (status=2) must not contribute cost")
+}
+
+func TestQueryUserProfitLoss_RemarkVoidExcluded(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedInventory(t, db, model.UserInventory{
+ ID: 2, UserID: 104, Status: 1, // valid status
+ ValueCents: 3000, OrderID: 0,
+ Remark: "void_20260101", // remark contains 'void' — must be excluded
+ })
+ result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{104}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, int64(0), result.TotalCost, "inventory with remark containing 'void' must not contribute cost")
+}
+
+func TestQueryUserProfitLoss_LegacyZeroOrderID(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedInventory(t, db, model.UserInventory{
+ ID: 3, UserID: 105, Status: 1, // valid
+ ValueCents: 2000, OrderID: 0, // legacy: order_id = 0 (no order linked)
+ Remark: "",
+ })
+ result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{105}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, int64(2000), result.TotalCost, "legacy inventory with order_id=0 MUST be included in cost (PNL-08)")
+}
+
+func TestQueryUserProfitLoss_AllUsers(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedOrder(t, db, model.Orders{ID: 10, UserID: 201, Status: 2, SourceType: 2, OrderNo: "O001", ActualAmount: 100})
+ seedOrder(t, db, model.Orders{ID: 11, UserID: 202, Status: 2, SourceType: 2, OrderNo: "O002", ActualAmount: 200})
+ result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{}) // empty UserIDs = all
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ userIDs := make(map[int64]bool)
+ for _, d := range result.Details {
+ userIDs[d.UserID] = true
+ }
+ require.True(t, userIDs[201], "user 201 must be in results")
+ require.True(t, userIDs[202], "user 202 must be in results")
+}
+
+func TestQueryUserProfitLoss_FilterByUserID(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedOrder(t, db, model.Orders{ID: 20, UserID: 301, Status: 2, SourceType: 2, OrderNo: "O003", ActualAmount: 500})
+ seedOrder(t, db, model.Orders{ID: 21, UserID: 302, Status: 2, SourceType: 2, OrderNo: "O004", ActualAmount: 600})
+ result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{301}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ for _, d := range result.Details {
+ require.Equal(t, int64(301), d.UserID, "only user 301 should appear")
+ }
+}
+
+func TestQueryUserProfitLoss_ProfitCalculation(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedOrder(t, db, model.Orders{ID: 30, UserID: 401, Status: 2, SourceType: 2, OrderNo: "O005", ActualAmount: 1000, DiscountAmount: 200})
+ seedInventory(t, db, model.UserInventory{ID: 10, UserID: 401, Status: 1, ValueCents: 800, OrderID: 30, Remark: ""})
+ result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{401}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, int64(1200), result.TotalRevenue)
+ require.Equal(t, int64(800), result.TotalCost)
+ require.Equal(t, int64(400), result.TotalProfit, "profit = revenue - cost")
+}
+
+func TestQueryUserProfitLoss_ResultShape(t *testing.T) {
+ svc, _ := newTestSvc(t)
+ result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.NotNil(t, result.Details, "Details must be non-nil slice")
+ require.NotNil(t, result.Breakdown, "Breakdown must be non-nil slice (empty for Phase 1)")
+}
+```
+
+If seedOrder/seedInventory fail because Orders or UserInventory don't have all required fields with zero values (SQLite is lenient), add zero values explicitly. If newTestSvc's AutoMigrate doesn't cover user_item_cards or system_item_cards (used in the cost scan JOINs), add `db.Exec("CREATE TABLE IF NOT EXISTS user_item_cards (id integer, card_id integer)")` and `db.Exec("CREATE TABLE IF NOT EXISTS system_item_cards (id integer, reward_multiplier_x1000 integer)")` in newTestSvc BEFORE the Scan test runs, or update newTestSvc to include empty table creation.
+
+
+ go test -v -run "TestQueryUser" ./internal/service/finance/
+
+
+ - `go test -v -run "TestQueryUser" ./internal/service/finance/` exits 0
+ - TestQueryUserProfitLoss_CashOrder PASS: TotalRevenue=1000
+ - TestQueryUserProfitLoss_RefundedOrderExcluded PASS: TotalRevenue=0
+ - TestQueryUserProfitLoss_VoidedInventoryExcluded PASS: TotalCost=0
+ - TestQueryUserProfitLoss_RemarkVoidExcluded PASS: TotalCost=0
+ - TestQueryUserProfitLoss_LegacyZeroOrderID PASS: TotalCost=2000
+ - TestQueryUserProfitLoss_AllUsers PASS: both users in Details
+ - TestQueryUserProfitLoss_FilterByUserID PASS: only user 301
+ - TestQueryUserProfitLoss_ProfitCalculation PASS: TotalProfit=400
+ - TestQueryUserProfitLoss_ResultShape PASS: Details and Breakdown non-nil
+ - `go test -v ./internal/service/finance/` exits 0 (all tests including Plan 01 tests still pass)
+
+ All QueryUserProfitLoss integration tests pass on SQLite. All PNL-02 through PNL-08, DIM-01, DIM-03, DIM-04 behaviors verified by automated tests.
+
+
+
+
+
+After all tasks complete:
+
+1. Package compiles: `go build ./internal/service/finance/` exits 0
+2. All tests pass: `go test -v ./internal/service/finance/` exits 0
+3. No write DB: `grep -r "GetDbW" ./internal/service/finance/` returns 0 matches
+4. Fan-out verified: `grep -c "Scan(&" ./internal/service/finance/query_user.go` returns >= 4
+5. Finance functions reused: `grep -E "ClassifyOrderSpending|ComputeProfit|ComputePrizeCostWithMultiplier" ./internal/service/finance/query_user.go | wc -l` returns >= 3
+6. No SQL CAST in tests: `grep "AS SIGNED" ./internal/service/finance/service_test.go | wc -l` returns 0
+
+
+
+- query_user.go: 4 fan-out scans (revenue, inventory cost, points cost, coupon cost), all Scan errors propagated, game-pass classified in Go, multiplier applied via ComputePrizeCostWithMultiplier
+- service.go: QueryUserProfitLoss dispatches to s.queryUser (no more stub)
+- service_test.go: 9+ integration tests for QueryUserProfitLoss all passing on SQLite
+- `go test -v ./internal/service/finance/` exits 0 with 13+ total PASS results
+
+
+
diff --git a/.planning/phases/01-core-pnl-functions/01-03-PLAN.md b/.planning/phases/01-core-pnl-functions/01-03-PLAN.md
new file mode 100644
index 0000000..4781232
--- /dev/null
+++ b/.planning/phases/01-core-pnl-functions/01-03-PLAN.md
@@ -0,0 +1,619 @@
+---
+phase: 01-core-pnl-functions
+plan: 03
+type: execute
+wave: 2
+depends_on:
+ - 01-01
+files_modified:
+ - internal/service/finance/query_activity.go
+ - internal/service/finance/service.go
+ - internal/service/finance/service_test.go
+autonomous: true
+requirements:
+ - PNL-02
+ - PNL-03
+ - PNL-04
+ - PNL-05
+ - PNL-06
+ - PNL-07
+ - PNL-08
+ - DIM-02
+ - DIM-03
+ - DIM-04
+ - RET-01
+ - RET-03
+ - QUA-03
+ - QUA-04
+ - QUA-05
+
+must_haves:
+ truths:
+ - "QueryActivityProfitLoss with a paid cash order returns Revenue = actual_amount + discount_amount attributed to that order's activity"
+ - "QueryActivityProfitLoss with a game-pass order returns Revenue = draw_count × activity_price for that activity"
+ - "QueryActivityProfitLoss excludes refunded orders (status=3 or status=4) from Revenue"
+ - "QueryActivityProfitLoss excludes voided inventory from Cost"
+ - "QueryActivityProfitLoss includes legacy inventory with order_id=0 in Cost"
+ - "QueryActivityProfitLoss with empty ActivityIDs returns aggregated result for all activities"
+ - "QueryActivityProfitLoss returns error (not nil) when a Scan() call fails"
+ - "1:1 order-to-activity: no revenue proration subquery — revenue attributed directly from orders.activity_id or via single JOIN"
+ artifacts:
+ - path: "internal/service/finance/query_activity.go"
+ provides: "QueryActivityProfitLoss implementation with fan-out scans"
+ exports: []
+ - path: "internal/service/finance/service.go"
+ provides: "Updated QueryActivityProfitLoss method body (replaces stub from Plan 01)"
+ - path: "internal/service/finance/service_test.go"
+ provides: "Integration tests for QueryActivityProfitLoss — activity dimension variants"
+ key_links:
+ - from: "internal/service/finance/query_activity.go"
+ to: "orders table"
+ via: "s.dbR.Table(TableNameOrders) JOIN activity_draw_logs JOIN activity_issues to get activity_id"
+ pattern: "activity_issues\\.activity_id"
+ - from: "internal/service/finance/query_activity.go"
+ to: "finance.ClassifyOrderSpending / ComputeProfit"
+ via: "Go-layer classification per order row after scan"
+ pattern: "ClassifyOrderSpending|ComputeProfit"
+ - from: "internal/service/finance/query_activity.go"
+ to: "user_inventory.activity_id"
+ via: "WHERE user_inventory.activity_id IN activityIDs for cost grouping"
+ pattern: "user_inventory\\.activity_id"
+---
+
+
+Implement QueryActivityProfitLoss in a new query_activity.go file using the same fan-out + in-memory merge pattern as Plan 02. The key difference from the user dimension: dimension key is activity_id (not user_id), and revenue is attributed to activities via the orders → activity_draw_logs → activity_issues → activities JOIN path (1:1 per D-01, no proration needed). The service.go stub is replaced with a real dispatch call.
+
+Purpose: Deliver the activity-dimension P&L function completing all Phase 1 requirements. Plan 03 runs in parallel with Plan 02 since they touch different files (query_activity.go vs query_user.go).
+
+Output: query_activity.go (implementation), service.go (updated), service_test.go (extended with activity tests).
+
+
+
+@~/.claude/get-shit-done/workflows/execute-plan.md
+@~/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/01-core-pnl-functions/1-CONTEXT.md
+@.planning/phases/01-core-pnl-functions/01-RESEARCH.md
+@.planning/phases/01-core-pnl-functions/01-01-SUMMARY.md
+
+
+
+
+
+From internal/service/finance/types.go (created in Plan 01):
+```go
+type ActivityProfitLossParams struct {
+ ActivityIDs []int64
+ AssetType AssetType
+ StartTime *time.Time
+ EndTime *time.Time
+}
+
+type ProfitLossDetail struct {
+ UserID, ActivityID int64
+ Revenue, Cost, Profit int64
+ ProfitRate float64
+}
+
+type ProfitLossResult struct {
+ TotalRevenue, TotalCost, TotalProfit int64
+ ProfitRate float64
+ Details []ProfitLossDetail
+ Breakdown []interface{}
+}
+```
+
+From internal/service/finance/service.go (created in Plan 01):
+```go
+type service struct { logger logger.CustomLogger; dbR *gorm.DB }
+// QueryActivityProfitLoss stub — REPLACE with: return s.queryActivity(ctx, params)
+```
+
+From internal/service/finance/profit_metrics.go (existing — MUST reuse):
+```go
+func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, discountAmount int64, remark string, gamePassValue int64) SpendingBreakdown
+func ComputeGamePassValue(drawCount, activityPrice int64) int64
+func ComputePrizeCostWithMultiplier(baseCost, multiplierX1000 int64) int64
+func ComputeProfit(spending, prizeCost int64) (int64, float64)
+```
+
+From internal/repository/mysql/model/:
+```go
+// Orders fields: user_id, status (2=paid,3=cancelled,4=refunded), source_type, order_no,
+// actual_amount, discount_amount, remark, item_card_id, created_at
+// UserInventory fields: activity_id, user_id, order_id, value_cents, status, remark, reward_id
+// UserPointsLedger fields: user_id, action, points, ref_table, ref_id, created_at
+// UserCouponLedger fields: user_id, change_amount, order_id, created_at
+// Key table constants:
+// model.TableNameOrders, model.TableNameUserInventory
+// model.TableNameUserPointsLedger, model.TableNameUserCouponLedger
+```
+
+Key decision from CONTEXT.md (D-01): 1:1 order-to-activity — NO revenue proration subquery needed.
+The activity dimension gets revenue by joining orders to activity_draw_logs to get which activity each order belongs to.
+Game-pass revenue: draw_count per activity_draw_logs × activities.price_draw, grouped by activity_id.
+
+
+
+
+
+ Task 1: Create query_activity.go — QueryActivityProfitLoss fan-out implementation
+
+ - internal/service/finance/service.go (verify service struct — dbR field, stub method to replace)
+ - internal/service/finance/types.go (verify ActivityProfitLossParams and ProfitLossResult fields)
+ - internal/service/finance/profit_metrics.go (verify ClassifyOrderSpending, ComputeProfit signatures)
+ - internal/service/finance/query_user.go (if Plan 02 completed — compare fan-out structure to mirror)
+ - internal/api/admin/dashboard_activity.go (lines 225-300 — cost scan pattern with activity_id grouping)
+ - .planning/phases/01-core-pnl-functions/1-CONTEXT.md (D-01: 1:1 order-to-activity; D-09: value_cents single source; D-02: game-pass per activity)
+ - .planning/phases/01-core-pnl-functions/01-RESEARCH.md (Pitfall 1: CAST; Pitfall 2: empty slice; Pitfall 5: scan error; Pitfall 6: SQLite compat)
+
+ internal/service/finance/query_activity.go
+
+ - Revenue scan: per-order rows joined to activity via activity_draw_logs → activity_issues → activities; classify per-row in Go using ClassifyOrderSpending(); sum revenue by activity_id
+ - Game-pass revenue: count draws per activity per order via activity_draw_logs JOIN; multiply by activities.price_draw using ComputeGamePassValue() in Go
+ - Cost scan (inventory): group by user_inventory.activity_id; WHERE status IN (1,3) AND remark NOT LIKE '%void%' AND (orders.status=2 OR order_id=0 OR order_id IS NULL); apply ComputePrizeCostWithMultiplier in Go (not SQL)
+ - Cost scan (points): join user_points_ledger to orders via ref_table='orders' and ref_id=order_no, then join to activity to get activity_id; OR use a simpler approach: join to activity_draw_logs via user_id+order filters — use the approach that SQLite can handle; SUM points by activity WHERE action='order_deduct'
+ - Cost scan (coupons): join user_coupon_ledger to orders (via order_id) to orders to activity_draw_logs to activity_issues to get activity_id; WHERE change_amount < 0 AND orders.status=2
+ - Every Scan() must check .Error and return fmt.Errorf("QueryActivityProfitLoss %s scan: %w", step, err)
+ - Empty ActivityIDs: do NOT add WHERE activity_id IN clause
+ - Time filters: add WHERE orders.created_at >= *StartTime only if non-nil
+ - Merge in Go via map[int64]*ProfitLossDetail keyed by activity_id
+ - Final aggregation: sum all Details into TotalRevenue/TotalCost; call ComputeProfit for totals
+ - Return &ProfitLossResult{..., Breakdown: []interface{}{}}
+
+
+Create `internal/service/finance/query_activity.go` with package `finance`.
+
+Imports:
+```go
+import (
+ "context"
+ "fmt"
+
+ "bindbox-game/internal/pkg/points"
+ "bindbox-game/internal/repository/mysql/model"
+ "gorm.io/gorm"
+)
+```
+
+Private method `(s *service) queryActivity(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error)`.
+
+**Step 1: Revenue scan** — per-order rows with activity attribution via draw logs JOIN.
+
+D-01 simplification: one order belongs to one activity. Join orders → activity_draw_logs → activity_issues → activities to get the activity_id per order. Use MAX(activity_issues.activity_id) since 1:1.
+
+```go
+type activityRevenueRow struct {
+ ActivityID int64
+ SourceType int32
+ OrderNo string
+ ActualAmount int64
+ DiscountAmount int64
+ Remark string
+ DrawCount int64 // COUNT(activity_draw_logs.id) for game-pass value
+ ActivityPrice int64 // activities.price_draw
+}
+var revenueRows []activityRevenueRow
+q := s.dbR.WithContext(ctx).
+ Table(model.TableNameOrders).
+ Select(`activity_issues.activity_id,
+ orders.source_type, orders.order_no,
+ orders.actual_amount, orders.discount_amount, orders.remark,
+ COUNT(activity_draw_logs.id) as draw_count,
+ COALESCE(MAX(activities.price_draw), 0) as activity_price`).
+ Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
+ Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
+ Joins("LEFT JOIN activities ON activities.id = activity_issues.activity_id").
+ Where("orders.status = ?", 2).
+ Group("orders.id, activity_issues.activity_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.remark")
+if len(params.ActivityIDs) > 0 {
+ q = q.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
+}
+if params.StartTime != nil {
+ q = q.Where("orders.created_at >= ?", *params.StartTime)
+}
+if params.EndTime != nil {
+ q = q.Where("orders.created_at <= ?", *params.EndTime)
+}
+if err := q.Scan(&revenueRows).Error; err != nil {
+ return nil, fmt.Errorf("QueryActivityProfitLoss revenue scan: %w", err)
+}
+```
+
+Merge revenue — classify per row in Go:
+```go
+resultMap := make(map[int64]*ProfitLossDetail)
+for _, r := range revenueRows {
+ gpValue := ComputeGamePassValue(r.DrawCount, r.ActivityPrice)
+ bd := ClassifyOrderSpending(r.SourceType, r.OrderNo, r.ActualAmount, r.DiscountAmount, r.Remark, gpValue)
+ if _, ok := resultMap[r.ActivityID]; !ok {
+ resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
+ }
+ resultMap[r.ActivityID].Revenue += bd.Total
+}
+```
+
+**Step 2: Inventory cost scan** — group by user_inventory.activity_id; apply multiplier in Go.
+
+```go
+type activityInventoryRow struct {
+ ActivityID int64
+ ValueCents int64
+ MultiplierX1000 int64
+}
+iq := s.dbR.WithContext(ctx).
+ Table(model.TableNameUserInventory).
+ Select(`user_inventory.activity_id,
+ user_inventory.value_cents,
+ COALESCE(system_item_cards.reward_multiplier_x1000, 1000) as multiplier_x1000`).
+ Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
+ Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
+ Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
+ 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)
+if len(params.ActivityIDs) > 0 {
+ iq = iq.Where("user_inventory.activity_id IN ?", params.ActivityIDs)
+}
+if params.StartTime != nil {
+ iq = iq.Where("user_inventory.created_at >= ?", *params.StartTime)
+}
+if params.EndTime != nil {
+ iq = iq.Where("user_inventory.created_at <= ?", *params.EndTime)
+}
+var inventoryRows []activityInventoryRow
+if err := iq.Scan(&inventoryRows).Error; err != nil {
+ return nil, fmt.Errorf("QueryActivityProfitLoss inventory cost scan: %w", err)
+}
+for _, r := range inventoryRows {
+ cost := ComputePrizeCostWithMultiplier(r.ValueCents, r.MultiplierX1000)
+ if _, ok := resultMap[r.ActivityID]; !ok {
+ resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
+ }
+ resultMap[r.ActivityID].Cost += cost
+}
+```
+
+**Step 3: Points cost scan** — link points to activity via activity_draw_logs.
+Since user_points_ledger.ref_table = 'orders' and ref_id = order_no, join via orders then to draw_logs:
+
+```go
+type activityPointsRow struct {
+ ActivityID int64
+ TotalPoints int64
+}
+pq := s.dbR.WithContext(ctx).
+ Table(model.TableNameUserPointsLedger).
+ Select("activity_issues.activity_id, SUM(-user_points_ledger.points) as total_points").
+ Joins("JOIN orders ON orders.order_no = user_points_ledger.ref_id AND user_points_ledger.ref_table = 'orders'").
+ Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
+ Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
+ Where("user_points_ledger.action = ?", "order_deduct").
+ Where("user_points_ledger.points < ?", 0).
+ Where("orders.status = ?", 2)
+if len(params.ActivityIDs) > 0 {
+ pq = pq.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
+}
+if params.StartTime != nil {
+ pq = pq.Where("user_points_ledger.created_at >= ?", *params.StartTime)
+}
+if params.EndTime != nil {
+ pq = pq.Where("user_points_ledger.created_at <= ?", *params.EndTime)
+}
+pq = pq.Group("activity_issues.activity_id")
+var pointsRows []activityPointsRow
+if err := pq.Scan(&pointsRows).Error; err != nil {
+ return nil, fmt.Errorf("QueryActivityProfitLoss points cost scan: %w", err)
+}
+rate := s.getPointsExchangeRate(ctx)
+for _, r := range pointsRows {
+ costCents := points.PointsToCents(r.TotalPoints, float64(rate))
+ if _, ok := resultMap[r.ActivityID]; !ok {
+ resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
+ }
+ resultMap[r.ActivityID].Cost += costCents
+}
+```
+
+**Step 4: Coupon cost scan** — link coupons to activity via orders → draw_logs.
+
+```go
+type activityCouponRow struct {
+ ActivityID int64
+ TotalCost int64
+}
+cq := s.dbR.WithContext(ctx).
+ Table(model.TableNameUserCouponLedger).
+ Select("activity_issues.activity_id, SUM(-user_coupon_ledger.change_amount) as total_cost").
+ Joins("JOIN orders ON orders.id = user_coupon_ledger.order_id").
+ Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
+ Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
+ Where("user_coupon_ledger.change_amount < ?", 0).
+ Where("orders.status = ?", 2)
+if len(params.ActivityIDs) > 0 {
+ cq = cq.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
+}
+if params.StartTime != nil {
+ cq = cq.Where("user_coupon_ledger.created_at >= ?", *params.StartTime)
+}
+if params.EndTime != nil {
+ cq = cq.Where("user_coupon_ledger.created_at <= ?", *params.EndTime)
+}
+cq = cq.Group("activity_issues.activity_id")
+var couponRows []activityCouponRow
+if err := cq.Scan(&couponRows).Error; err != nil {
+ return nil, fmt.Errorf("QueryActivityProfitLoss coupon cost scan: %w", err)
+}
+for _, r := range couponRows {
+ if _, ok := resultMap[r.ActivityID]; !ok {
+ resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
+ }
+ resultMap[r.ActivityID].Cost += r.TotalCost
+}
+```
+
+**Step 5: Apply ComputeProfit and aggregate totals.**
+
+```go
+details := make([]ProfitLossDetail, 0, len(resultMap))
+var totalRevenue, totalCost int64
+for _, d := range resultMap {
+ d.Profit, d.ProfitRate = ComputeProfit(d.Revenue, d.Cost)
+ totalRevenue += d.Revenue
+ totalCost += d.Cost
+ details = append(details, *d)
+}
+totalProfit, profitRate := ComputeProfit(totalRevenue, totalCost)
+return &ProfitLossResult{
+ TotalRevenue: totalRevenue,
+ TotalCost: totalCost,
+ TotalProfit: totalProfit,
+ ProfitRate: profitRate,
+ Details: details,
+ Breakdown: []interface{}{},
+}, nil
+```
+
+NOTE: `getPointsExchangeRate` is defined in query_user.go in the same package — it is accessible from query_activity.go without redefinition. Do NOT redefine it.
+
+**Update service.go stub** — replace QueryActivityProfitLoss body:
+```go
+func (s *service) QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error) {
+ return s.queryActivity(ctx, params)
+}
+```
+
+CRITICAL rules (same as Plan 02):
+- NEVER call GetDbW() — only s.dbR
+- NEVER skip .Error check on any Scan()
+- NEVER add WHERE activity_id IN when params.ActivityIDs is empty
+- NEVER use CAST(AS SIGNED) in SQL — apply multiplier via ComputePrizeCostWithMultiplier in Go
+- NEVER use COALESCE fallback chain for value_cents — D-09: value_cents only
+- NEVER re-implement IsGamePassOrder in SQL CASE expressions — classify in Go via ClassifyOrderSpending
+
+
+ go build ./internal/service/finance/ && go test -v -run "TestQueryActivity" ./internal/service/finance/
+
+
+ - internal/service/finance/query_activity.go exists
+ - File contains `func (s *service) queryActivity(`
+ - File does NOT contain `func (s *service) getPointsExchangeRate(` (defined in query_user.go, not here)
+ - File contains at least 4 `Scan(&` calls (revenue, inventory, points, coupon)
+ - File contains `ClassifyOrderSpending(` in Go layer
+ - File contains `ComputeProfit(` for totals
+ - File contains `ComputePrizeCostWithMultiplier(` in Go layer (not inside SQL string)
+ - File contains `points.PointsToCents(`
+ - File does NOT contain `GetDbW`
+ - File does NOT contain `CAST(` (no SQL-layer casting — multiplier in Go)
+ - File does NOT contain `COALESCE(NULLIF(user_inventory.value_cents` (no fallback chain)
+ - For each `Scan(` call, there is a `if err :=` error check with `return nil, fmt.Errorf`
+ - service.go QueryActivityProfitLoss body contains `return s.queryActivity(ctx, params)`
+ - `go build ./internal/service/finance/` exits 0
+
+ query_activity.go implements QueryActivityProfitLoss with 4 fan-out scans attributed to activity dimension, all errors propagated, game-pass classified in Go, multiplier applied in Go.
+
+
+
+ Task 2: Add QueryActivityProfitLoss integration tests to service_test.go
+
+ - internal/service/finance/service_test.go (existing helpers and tests from Plans 01+02)
+ - internal/service/finance/query_activity.go (just implemented — verify scan logic to test correctly)
+ - internal/repository/mysql/model/orders.gen.go (Orders struct fields for seeding)
+ - internal/repository/mysql/model/user_inventory.gen.go (UserInventory fields)
+ - .planning/phases/01-core-pnl-functions/01-RESEARCH.md (Pitfall 6: SQLite compat — avoid CAST AS SIGNED; activity JOIN tables must exist for SQLite AutoMigrate or CREATE TABLE)
+
+ internal/service/finance/service_test.go
+
+ - TestQueryActivityProfitLoss_EmptyParams_ReturnsResult: call with empty params, assert no error, result not nil
+ - TestQueryActivityProfitLoss_CashOrderRevenue: seed order + draw_log + activity, assert Revenue = actual + discount
+ - TestQueryActivityProfitLoss_RefundedOrderExcluded: seed refunded order (status=4), assert Revenue=0
+ - TestQueryActivityProfitLoss_VoidedInventoryExcluded: seed inventory with status=2 for activity, assert Cost=0
+ - TestQueryActivityProfitLoss_LegacyZeroOrderID: seed inventory with order_id=0 and activity_id set, assert included in Cost
+ - TestQueryActivityProfitLoss_AllActivities: seed 2 activities with orders, call with empty ActivityIDs, assert both in Details
+ - TestQueryActivityProfitLoss_FilterByActivityID: seed 2 activities, call with one ActivityID, assert only that activity in Details
+ - TestQueryActivityProfitLoss_ProfitCalculation: revenue - cost = profit
+ - TestQueryActivityProfitLoss_ResultShape: Details and Breakdown non-nil
+ - For SQLite compat: seed draw log tables with db.Exec CREATE TABLE IF NOT EXISTS or via AutoMigrate using struct (if model exists); the query JOINs activity_draw_logs, activity_issues, activities — these tables must exist in test DB
+
+
+Append activity integration tests to `internal/service/finance/service_test.go`.
+
+The activity-dimension tests require activity_draw_logs, activity_issues, and activities tables to exist in the SQLite test DB. Update `newTestSvc` if needed to create these tables (use db.Exec for tables without model structs, or check if model structs exist for AutoMigrate).
+
+First check if model.ActivityDrawLogs, model.ActivityIssues, model.Activities exist in the model package — if they do, add them to AutoMigrate; if not, create minimal SQLite tables via db.Exec.
+
+For the tests, create a helper `seedActivityWithDrawLog` that seeds an activity, an activity_issue, and an activity_draw_log linked to a given order:
+
+```go
+// seedActivity creates minimal activity + issue + draw_log for JOIN tests
+// activityID: the activity.id, orderID: the linked order.id, userID: the draw user
+func seedActivitySetup(t *testing.T, db *gorm.DB, activityID, issueID, orderID, userID int64, priceDraw int64) {
+ t.Helper()
+ // Create tables if not covered by AutoMigrate
+ db.Exec("CREATE TABLE IF NOT EXISTS activities (id integer primary key, price_draw integer not null default 0)")
+ db.Exec("CREATE TABLE IF NOT EXISTS activity_issues (id integer primary key, activity_id integer not null)")
+ db.Exec("CREATE TABLE IF NOT EXISTS activity_draw_logs (id integer primary key, order_id integer, issue_id integer, user_id integer)")
+ // Seed rows
+ require.NoError(t, db.Exec("INSERT OR IGNORE INTO activities (id, price_draw) VALUES (?, ?)", activityID, priceDraw).Error)
+ require.NoError(t, db.Exec("INSERT OR IGNORE INTO activity_issues (id, activity_id) VALUES (?, ?)", issueID, activityID).Error)
+ require.NoError(t, db.Exec("INSERT OR IGNORE INTO activity_draw_logs (id, order_id, issue_id, user_id) VALUES (?, ?, ?, ?)", orderID*100+issueID, orderID, issueID, userID).Error)
+}
+```
+
+Tests:
+```go
+func TestQueryActivityProfitLoss_CashOrderRevenue(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedOrder(t, db, model.Orders{
+ ID: 50, UserID: 501, Status: 2,
+ SourceType: 2, OrderNo: "A001",
+ ActualAmount: 600, DiscountAmount: 150,
+ })
+ seedActivitySetup(t, db, 1001, 2001, 50, 501, 100)
+ result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1001}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, int64(750), result.TotalRevenue, "cash revenue = actual(600) + discount(150)")
+}
+
+func TestQueryActivityProfitLoss_RefundedOrderExcluded(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedOrder(t, db, model.Orders{
+ ID: 51, UserID: 502, Status: 4, // refunded
+ SourceType: 2, OrderNo: "A002",
+ ActualAmount: 800, DiscountAmount: 0,
+ })
+ seedActivitySetup(t, db, 1002, 2002, 51, 502, 100)
+ result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1002}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, int64(0), result.TotalRevenue, "refunded order must not contribute revenue")
+}
+
+func TestQueryActivityProfitLoss_VoidedInventoryExcluded(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedInventory(t, db, model.UserInventory{
+ ID: 20, UserID: 503, ActivityID: 1003,
+ Status: 2, // voided status
+ ValueCents: 4000, OrderID: 0,
+ })
+ result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1003}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, int64(0), result.TotalCost, "voided inventory must not contribute cost")
+}
+
+func TestQueryActivityProfitLoss_LegacyZeroOrderID(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedInventory(t, db, model.UserInventory{
+ ID: 21, UserID: 504, ActivityID: 1004,
+ Status: 1, ValueCents: 3500, OrderID: 0,
+ Remark: "",
+ })
+ result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1004}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, int64(3500), result.TotalCost, "legacy inventory with order_id=0 MUST be included in cost (PNL-08)")
+}
+
+func TestQueryActivityProfitLoss_AllActivities(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedOrder(t, db, model.Orders{ID: 60, UserID: 601, Status: 2, SourceType: 2, OrderNo: "A010", ActualAmount: 100})
+ seedOrder(t, db, model.Orders{ID: 61, UserID: 602, Status: 2, SourceType: 2, OrderNo: "A011", ActualAmount: 200})
+ seedActivitySetup(t, db, 2001, 3001, 60, 601, 50)
+ seedActivitySetup(t, db, 2002, 3002, 61, 602, 50)
+ result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{}) // empty = all
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ actIDs := make(map[int64]bool)
+ for _, d := range result.Details {
+ actIDs[d.ActivityID] = true
+ }
+ require.True(t, actIDs[2001], "activity 2001 must be in results")
+ require.True(t, actIDs[2002], "activity 2002 must be in results")
+}
+
+func TestQueryActivityProfitLoss_FilterByActivityID(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedOrder(t, db, model.Orders{ID: 70, UserID: 701, Status: 2, SourceType: 2, OrderNo: "A020", ActualAmount: 300})
+ seedOrder(t, db, model.Orders{ID: 71, UserID: 702, Status: 2, SourceType: 2, OrderNo: "A021", ActualAmount: 400})
+ seedActivitySetup(t, db, 3001, 4001, 70, 701, 50)
+ seedActivitySetup(t, db, 3002, 4002, 71, 702, 50)
+ result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{3001}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ for _, d := range result.Details {
+ require.Equal(t, int64(3001), d.ActivityID, "only activity 3001 should appear")
+ }
+}
+
+func TestQueryActivityProfitLoss_ProfitCalculation(t *testing.T) {
+ svc, db := newTestSvc(t)
+ seedOrder(t, db, model.Orders{ID: 80, UserID: 801, Status: 2, SourceType: 2, OrderNo: "A030", ActualAmount: 2000, DiscountAmount: 500})
+ seedActivitySetup(t, db, 4001, 5001, 80, 801, 100)
+ seedInventory(t, db, model.UserInventory{ID: 30, UserID: 801, ActivityID: 4001, Status: 1, ValueCents: 1200, OrderID: 80})
+ result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{4001}})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, int64(2500), result.TotalRevenue, "revenue = actual(2000) + discount(500)")
+ require.Equal(t, int64(1200), result.TotalCost)
+ require.Equal(t, int64(1300), result.TotalProfit, "profit = 2500 - 1200")
+}
+
+func TestQueryActivityProfitLoss_ResultShape(t *testing.T) {
+ svc, _ := newTestSvc(t)
+ result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{})
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.NotNil(t, result.Details, "Details must be non-nil slice")
+ require.NotNil(t, result.Breakdown, "Breakdown must be non-nil empty slice")
+}
+```
+
+NOTE on SQLite compat: The revenue scan JOINs activity_draw_logs, activity_issues, activities. For tests without these rows seeded, the INNER JOIN will return 0 rows (not error) — so TestQueryActivityProfitLoss_VoidedInventoryExcluded and TestQueryActivityProfitLoss_LegacyZeroOrderID only test the cost scan (inventory), which uses user_inventory.activity_id directly, not the draw_log JOIN. This is correct — cost and revenue are independent fan-out scans.
+
+
+ go test -v -run "TestQueryActivity" ./internal/service/finance/
+
+
+ - `go test -v -run "TestQueryActivity" ./internal/service/finance/` exits 0
+ - TestQueryActivityProfitLoss_CashOrderRevenue PASS: TotalRevenue=750
+ - TestQueryActivityProfitLoss_RefundedOrderExcluded PASS: TotalRevenue=0
+ - TestQueryActivityProfitLoss_VoidedInventoryExcluded PASS: TotalCost=0
+ - TestQueryActivityProfitLoss_LegacyZeroOrderID PASS: TotalCost=3500
+ - TestQueryActivityProfitLoss_AllActivities PASS: both activities in Details
+ - TestQueryActivityProfitLoss_FilterByActivityID PASS: only activity 3001 in Details
+ - TestQueryActivityProfitLoss_ProfitCalculation PASS: TotalProfit=1300
+ - TestQueryActivityProfitLoss_ResultShape PASS: Details and Breakdown non-nil
+ - `go test -v ./internal/service/finance/` exits 0 (ALL tests pass including Plan 01 and 02 tests)
+
+ All QueryActivityProfitLoss integration tests pass on SQLite. All DIM-02, DIM-03, DIM-04, PNL-02 through PNL-08, RET-01, RET-03 behaviors verified for the activity dimension.
+
+
+
+
+
+After all tasks complete:
+
+1. Package compiles: `go build ./internal/service/finance/` exits 0
+2. All tests pass: `go test -v ./internal/service/finance/` exits 0
+3. No write DB: `grep -r "GetDbW" ./internal/service/finance/` returns 0 matches
+4. Fan-out verified: `grep -c "Scan(&" ./internal/service/finance/query_activity.go` returns >= 4
+5. Finance functions reused: `grep -E "ClassifyOrderSpending|ComputeProfit|ComputePrizeCostWithMultiplier" ./internal/service/finance/query_activity.go | wc -l` returns >= 3
+6. No duplicate helper: `grep -c "getPointsExchangeRate" ./internal/service/finance/query_activity.go` returns 1 (call) not 2 (definition would mean duplicate)
+7. Full build: `go build ./...` exits 0
+
+
+
+- query_activity.go: 4 fan-out scans attributed to activity dimension, all errors propagated, game-pass classified in Go, multiplier applied in Go, no GetDbW()
+- service.go: QueryActivityProfitLoss dispatches to s.queryActivity
+- service_test.go: 8+ TestQueryActivity* tests all PASS on SQLite
+- `go test -v ./internal/service/finance/` exits 0 with 20+ total PASS results
+- `go build ./...` exits 0
+
+
+
diff --git a/.planning/phases/01-core-pnl-functions/01-04-PLAN.md b/.planning/phases/01-core-pnl-functions/01-04-PLAN.md
new file mode 100644
index 0000000..79a4196
--- /dev/null
+++ b/.planning/phases/01-core-pnl-functions/01-04-PLAN.md
@@ -0,0 +1,194 @@
+---
+phase: 01-core-pnl-functions
+plan: 04
+type: execute
+wave: 3
+depends_on:
+ - 01-02
+ - 01-03
+files_modified: []
+autonomous: true
+requirements:
+ - QUA-01
+ - QUA-02
+ - QUA-03
+ - QUA-04
+ - QUA-05
+ - PNL-06
+ - RET-01
+ - RET-03
+ - AST-01
+
+must_haves:
+ truths:
+ - "Full test suite passes: go test -v ./internal/service/finance/... exits 0"
+ - "Full build passes: go build ./... exits 0"
+ - "No GetDbW() call exists anywhere in the finance package"
+ - "All Scan() calls in query_user.go and query_activity.go have corresponding error checks"
+ - "finance.* utility functions (ClassifyOrderSpending, ComputeProfit, etc.) are called from query_*.go, not reimplemented"
+ - "Fan-out pattern: query_user.go has >= 4 Scan calls; query_activity.go has >= 4 Scan calls"
+ - "All monetary struct fields in types.go are int64 (no float64 for monetary values except ProfitRate)"
+ artifacts:
+ - path: "internal/service/finance/types.go"
+ provides: "Verified: all monetary fields int64, ProfitRate float64 only"
+ - path: "internal/service/finance/query_user.go"
+ provides: "Verified: >=4 Scan calls, all error-checked, finance functions called"
+ - path: "internal/service/finance/query_activity.go"
+ provides: "Verified: >=4 Scan calls, all error-checked, finance functions called"
+ key_links:
+ - from: "internal/service/finance package"
+ to: "existing profit_metrics_test.go"
+ via: "go test -v ./internal/service/finance/... — all tests including profit_metrics tests must pass"
+ pattern: "PASS"
+---
+
+
+Run all verification checks for Phase 1. No new files are created — this plan executes a series of automated checks to confirm that all 20 requirements (PNL-01 through QUA-05) are satisfied before declaring Phase 1 complete.
+
+Purpose: Gate phase completion. Catches any drift between plans that ran in parallel (Plans 02 and 03), verifies static code properties that tests alone cannot guarantee, and confirms the full build is green.
+
+Output: Evidence that all Phase 1 requirements are met. Creates no new files.
+
+
+
+@~/.claude/get-shit-done/workflows/execute-plan.md
+@~/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/01-core-pnl-functions/1-CONTEXT.md
+@.planning/phases/01-core-pnl-functions/01-02-SUMMARY.md
+@.planning/phases/01-core-pnl-functions/01-03-SUMMARY.md
+
+
+
+
+
+ Task 1: Run full test suite and static code checks
+
+ - internal/service/finance/query_user.go (verify 4+ Scan calls and finance function calls before running checks)
+ - internal/service/finance/query_activity.go (verify 4+ Scan calls and finance function calls)
+ - internal/service/finance/types.go (verify int64 monetary fields)
+ - internal/service/finance/service.go (verify no GetDbW — both stubs should dispatch to queryUser/queryActivity)
+
+
+
+Run the following verification sequence in order. For each check, output PASS or FAIL with evidence.
+
+**Check 1: Full test suite (QUA-03, QUA-04, QUA-05)**
+```bash
+go test -v --cover ./internal/service/finance/...
+```
+Expected: All tests PASS, 0 failures.
+
+**Check 2: Full project build (QUA-01)**
+```bash
+go build ./...
+```
+Expected: exits 0, no errors.
+
+**Check 3: No GetDbW() in finance package (QUA-02)**
+```bash
+grep -r "GetDbW" ./internal/service/finance/
+```
+Expected: empty output (zero matches). If any match found, FAIL — locate and remove the call.
+
+**Check 4: Fan-out scan count — user (QUA-05)**
+```bash
+grep -c "Scan(&" ./internal/service/finance/query_user.go
+```
+Expected: >= 4.
+
+**Check 5: Fan-out scan count — activity (QUA-05)**
+```bash
+grep -c "Scan(&" ./internal/service/finance/query_activity.go
+```
+Expected: >= 4.
+
+**Check 6: Scan errors all checked (QUA-03)**
+```bash
+grep -B1 "Scan(&" ./internal/service/finance/query_user.go | grep -c "if err :="
+grep -B1 "Scan(&" ./internal/service/finance/query_activity.go | grep -c "if err :="
+```
+Expected: count matches the number of Scan calls in each file. If any Scan is not wrapped in `if err :=`, locate and fix.
+
+**Check 7: Finance utility functions reused (QUA-04)**
+```bash
+grep -E "ClassifyOrderSpending|ComputeGamePassValue|ComputePrizeCostWithMultiplier|ComputeProfit" ./internal/service/finance/query_user.go | wc -l
+grep -E "ClassifyOrderSpending|ComputeGamePassValue|ComputePrizeCostWithMultiplier|ComputeProfit" ./internal/service/finance/query_activity.go | wc -l
+```
+Expected: >= 3 matches in each file.
+
+**Check 8: No SQL-embedded CAST AS SIGNED (SQLite test compat)**
+```bash
+grep "AS SIGNED" ./internal/service/finance/query_user.go ./internal/service/finance/query_activity.go | wc -l
+```
+Expected: 0. Multipliers must be applied in Go via ComputePrizeCostWithMultiplier.
+
+**Check 9: int64 monetary fields (RET-03)**
+```bash
+grep -E "Revenue|Cost|Profit\b" ./internal/service/finance/types.go | grep "float64"
+```
+Expected: 0 matches. Only `ProfitRate float64` is allowed; all Revenue/Cost/Profit fields must be int64.
+
+**Check 10: AssetType constants (AST-01)**
+```bash
+grep -A7 "AssetTypeAll" ./internal/service/finance/types.go
+```
+Expected: shows All=0, Points=1, Coupon=2, ItemCard=3, Product=4, Fragment=5.
+
+If any check fails:
+1. Identify which file has the issue from the check output
+2. Read the file
+3. Make the targeted fix (do not rewrite the whole file)
+4. Re-run the failing check to confirm it now passes
+5. Re-run `go test -v ./internal/service/finance/...` to confirm tests still pass
+
+
+ go test -v --cover ./internal/service/finance/... && go build ./... && echo "ALL PHASE 1 CHECKS PASSED"
+
+
+ - `go test -v --cover ./internal/service/finance/...` exits 0 with PASS for all tests
+ - `go build ./...` exits 0
+ - `grep -r "GetDbW" ./internal/service/finance/` returns empty (0 matches)
+ - `grep -c "Scan(&" ./internal/service/finance/query_user.go` returns >= 4
+ - `grep -c "Scan(&" ./internal/service/finance/query_activity.go` returns >= 4
+ - `grep -E "ClassifyOrderSpending|ComputeProfit" ./internal/service/finance/query_user.go | wc -l` returns >= 2
+ - `grep -E "ClassifyOrderSpending|ComputeProfit" ./internal/service/finance/query_activity.go | wc -l` returns >= 2
+ - `grep "AS SIGNED" ./internal/service/finance/query_*.go | wc -l` returns 0
+ - `grep -E "Revenue|Cost|Profit\b" ./internal/service/finance/types.go | grep "float64" | wc -l` returns 0
+ - All 10 verification checks above show PASS or expected result
+
+ All 10 static and automated checks pass. Phase 1 is complete — QueryUserProfitLoss and QueryActivityProfitLoss are implemented, tested, and meet all 20 Phase 1 requirements.
+
+
+
+
+
+Phase 1 is complete when ALL of the following are simultaneously true:
+
+1. `go test -v --cover ./internal/service/finance/...` — all PASS, coverage reported
+2. `go build ./...` — exits 0
+3. `grep -r "GetDbW" ./internal/service/finance/` — 0 matches
+4. `grep -c "Scan(&" query_user.go` — >= 4
+5. `grep -c "Scan(&" query_activity.go` — >= 4
+6. All finance.* utility functions called (not reimplemented) in query_*.go
+7. All monetary fields in types.go are int64 (ProfitRate is the only float64)
+8. VALIDATION.md nyquist_compliant updated to true
+
+
+
+- All 20 Phase 1 requirements (PNL-01 through QUA-05) verified as complete
+- Full test suite green including existing profit_metrics tests
+- Full project build clean
+- Finance package has zero write-DB references
+- Phase 1 ready for hand-off to Phase 2
+
+
+