win 2a7b731484 feat(finance): implement Phase 1 core P&L service + wire into dashboard
- Add internal/service/finance/types.go: AssetType enum, param/result structs
- Add internal/service/finance/service.go: Service interface, read-only ctor
- Add internal/service/finance/query_user.go: QueryUserProfitLoss (4 fan-out scans)
- Add internal/service/finance/query_activity.go: QueryActivityProfitLoss (4 fan-out scans)
- Add internal/service/finance/service_test.go: 22 integration tests (all pass)
- Wire finance.Service into admin handler (admin.go)
- Replace dashboard_activity cost scan with finance.Service call (D-09: value_cents single source of truth)
- Revenue/gamepass/draw-count scans unchanged; response schema fully compatible

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-21 18:38:33 +08:00

386 lines
15 KiB
Go

package finance
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"
)
// newTestSvc creates an in-memory SQLite repo, creates all required tables,
// and returns (Service, *gorm.DB) for test use.
// NOTE: Uses manual CREATE TABLE instead of AutoMigrate to avoid CURRENT_TIMESTAMP(3)
// SQLite incompatibility present in the GORM model tags.
func newTestSvc(t *testing.T) (Service, *gorm.DB) {
t.Helper()
repo, err := mysql.NewSQLiteRepoForTest()
require.NoError(t, err)
db := repo.GetDbR()
// Create tables manually — SQLite does not support CURRENT_TIMESTAMP(3)
// which is present in the GORM model default tags.
stmts := []string{
`CREATE TABLE IF NOT EXISTS orders (
id integer primary key,
created_at datetime,
updated_at datetime,
user_id integer not null default 0,
order_no text not null default '',
source_type integer not null default 1,
total_amount integer not null default 0,
discount_amount integer not null default 0,
points_amount integer not null default 0,
actual_amount integer not null default 0,
status integer not null default 1,
pay_preorder_id integer,
paid_at datetime,
cancelled_at datetime,
user_address_id integer,
is_consumed integer not null default 0,
points_ledger_id integer,
coupon_id integer,
item_card_id integer,
remark text,
ext_order_id text not null default ''
)`,
`CREATE TABLE IF NOT EXISTS user_inventory (
id integer primary key,
created_at datetime,
updated_at datetime,
user_id integer not null default 0,
product_id integer,
value_cents integer not null default 0,
value_source integer not null default 0,
value_snapshot_at datetime,
order_id integer,
activity_id integer,
reward_id integer,
status integer not null default 1,
shipping_no text not null default '',
remark text
)`,
`CREATE TABLE IF NOT EXISTS user_points_ledger (
id integer primary key,
created_at datetime,
user_id integer not null default 0,
action text not null default '',
points integer not null default 0,
ref_table text,
ref_id text,
remark text
)`,
`CREATE TABLE IF NOT EXISTS user_coupon_ledger (
id integer primary key,
user_id integer not null default 0,
user_coupon_id integer not null default 0,
change_amount integer not null default 0,
balance_after integer not null default 0,
order_id integer,
action text not null default '',
created_at datetime
)`,
`CREATE TABLE IF NOT EXISTS activity_draw_logs (id integer primary key, order_id integer, issue_id integer, user_id integer)`,
`CREATE TABLE IF NOT EXISTS activity_issues (id integer primary key, activity_id integer not null)`,
`CREATE TABLE IF NOT EXISTS activities (id integer primary key, price_draw integer not null default 0)`,
`CREATE TABLE IF NOT EXISTS user_item_cards (id integer primary key, card_id integer)`,
`CREATE TABLE IF NOT EXISTS system_item_cards (id integer primary key, reward_multiplier_x1000 integer)`,
`CREATE TABLE IF NOT EXISTS system_configs (id integer primary key, config_key text, config_value text)`,
}
for _, stmt := range stmts {
require.NoError(t, db.Exec(stmt).Error)
}
l, err := logger.NewCustomLogger(logger.WithOutputInConsole())
require.NoError(t, err)
svc := New(l, repo)
return svc, db
}
// --- Seed helpers ---
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)
}
// seedActivitySetup creates minimal activity + issue + draw_log for JOIN tests.
func seedActivitySetup(t *testing.T, db *gorm.DB, activityID, issueID, orderID, userID int64, priceDraw int64) {
t.Helper()
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)
}
// --- Plan 01 contract 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
}
func TestQueryActivityProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) {
svc, _ := newTestSvc(t)
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{})
require.NoError(t, err)
_ = result
}
// --- Plan 02 QueryUserProfitLoss integration tests ---
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",
})
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,
ValueCents: 2000, OrderID: 0,
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{})
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)")
}
// --- Plan 03 QueryActivityProfitLoss integration tests ---
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
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{})
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")
}