4 plans across 3 waves: - 01-01 (wave 1): package scaffold — types.go, service.go, service_test.go - 01-02 (wave 2): QueryUserProfitLoss — query_user.go + user integration tests - 01-03 (wave 2, parallel): QueryActivityProfitLoss — query_activity.go + activity tests - 01-04 (wave 3): phase verification — static checks + full test suite gate Covers all 20 Phase 1 requirements: PNL-01..08, DIM-01..04, RET-01/03, AST-01, QUA-01..05.
29 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-core-pnl-functions | 02 | execute | 2 |
|
|
true |
|
|
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).
<execution_context>
@/.claude/get-shit-done/workflows/execute-plan.md
@/.claude/get-shit-done/templates/summary.md
</execution_context>
From internal/service/finance/types.go (created in Plan 01):
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):
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):
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):
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:
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:
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):
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:
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:
type userInventoryRow struct {
UserID int64
ValueCents int64
MultiplierX1000 int64
}
Query:
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.
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.
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.
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):
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:
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/
<acceptance_criteria>
- 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 correspondingif err :=error check - service.go QueryUserProfitLoss body contains
return s.queryUser(ctx, params) go build ./internal/service/finance/exits 0 </acceptance_criteria> 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.
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.
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/
<acceptance_criteria>
- 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)
</acceptance_criteria>
All QueryUserProfitLoss integration tests pass on SQLite. All PNL-02 through PNL-08, DIM-01, DIM-03, DIM-04 behaviors verified by automated tests.
- Package compiles:
go build ./internal/service/finance/exits 0 - All tests pass:
go test -v ./internal/service/finance/exits 0 - No write DB:
grep -r "GetDbW" ./internal/service/finance/returns 0 matches - Fan-out verified:
grep -c "Scan(&" ./internal/service/finance/query_user.goreturns >= 4 - Finance functions reused:
grep -E "ClassifyOrderSpending|ComputeProfit|ComputePrizeCostWithMultiplier" ./internal/service/finance/query_user.go | wc -lreturns >= 3 - No SQL CAST in tests:
grep "AS SIGNED" ./internal/service/finance/service_test.go | wc -lreturns 0
<success_criteria>
- 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 </success_criteria>