win b99bcbd06f docs(01-core-pnl-functions): create phase 1 plans
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.
2026-03-21 17:27:58 +08:00

20 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 01 execute 1
internal/service/finance/types.go
internal/service/finance/service.go
internal/service/finance/service_test.go
true
PNL-01
RET-01
RET-03
AST-01
DIM-01
DIM-02
DIM-03
DIM-04
QUA-01
QUA-02
truths artifacts key_links
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
path provides exports
internal/service/finance/types.go AssetType enum, UserProfitLossParams, ActivityProfitLossParams, ProfitLossDetail, ProfitLossResult
AssetType
AssetTypeAll
AssetTypePoints
AssetTypeCoupon
AssetTypeItemCard
AssetTypeProduct
AssetTypeFragment
UserProfitLossParams
ActivityProfitLossParams
ProfitLossDetail
ProfitLossResult
path provides exports
internal/service/finance/service.go Service interface + New() constructor
Service
New
path provides
internal/service/finance/service_test.go Test helper newTestSvc() and seed helpers for orders/inventory/points/coupons
from to via pattern
internal/service/finance/service.go internal/repository/mysql/mysql.go New(l logger.CustomLogger, db mysql.Repo) — calls db.GetDbR() only GetDbR()
from to via pattern
internal/service/finance/types.go internal/service/finance/query_user.go (Plan 02) UserProfitLossParams consumed by QueryUserProfitLoss 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.

<execution_context> @/.claude/get-shit-done/workflows/execute-plan.md @/.claude/get-shit-done/templates/summary.md </execution_context>

@.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:

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:

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

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:

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:

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:

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:

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

// 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):

// 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:

import (
    "context"

    "bindbox-game/internal/pkg/logger"
    "bindbox-game/internal/repository/mysql"
    "gorm.io/gorm"
)

Define Service interface and struct:

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

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" <acceptance_criteria> - 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) </acceptance_criteria> 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:

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

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

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:

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/ <acceptance_criteria>
    • 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) </acceptance_criteria> 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)

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/01-core-pnl-functions/01-01-SUMMARY.md`