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.
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 |
|
true |
|
|
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>
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.
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.
- Package compiles:
go build ./internal/service/finance/exits 0 - All tests green:
go test -v ./internal/service/finance/— must show PASS for all tests including existing profit_metrics tests - No write DB leak:
grep -r "GetDbW" ./internal/service/finance/returns 0 matches - AssetType values correct:
grep -A8 "AssetTypeAll" internal/service/finance/types.goshows All=0 through Fragment=5 - Pointer time fields:
grep "StartTime\|EndTime" internal/service/finance/types.go | grep "\*time.Time"returns 2 matches - 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>