Compare commits

..

2 Commits

Author SHA1 Message Date
fb4b266bac fix(channel): 统一渠道分析时间筛选范围
让渠道分析弹窗的概览卡片和趋势图共用同一时间筛选范围,
并将默认行为调整为历史全量展示。前端改为在未选择日期时
不再回退最近 12 天,后端新增统一时间窗口解析逻辑,使
overview 与 daily 在全量、指定区间和 days 模式下保持一致。
2026-04-09 17:51:42 +08:00
03214dddf2 fix(channel): align channel cost with draw-source attribution
Replace channel cost aggregation with draw-source based cost calculation
that follows activity profit-loss logic and keeps cost attribution on the
original ordering user's channel. Update channel stats tests to cover the
new cost path and related schema fields.
2026-04-09 17:04:30 +08:00
2 changed files with 182 additions and 114 deletions

View File

@ -234,6 +234,56 @@ func (s *service) calcCostByInventory(ctx context.Context, channelID int64, date
return total, byDate
}
// calcCostByDrawSource 按订单/抽奖来源统计渠道奖品成本。
// 成本口径与活动盈亏保持一致:
// - 来源activity_draw_logs + activity_reward_settings + products.cost_price
// - 数量drop_quantity默认 1
// - 倍数:命中特定道具卡翻倍规则时 +1 份
//
// 注意:这里按 orders.user_id -> users.channel_id 归因,而不是按当前 user_inventory.user_id。
// 这样 inventory 转赠后,成本仍归到原下单用户所属渠道。
func (s *service) calcCostByDrawSource(ctx context.Context, channelID int64, dateFmt string, startDate, endDate *time.Time) (int64, map[string]int64) {
type costRow struct {
CostCents int64
PaidAt time.Time
}
q := s.readDB.ActivityDrawLogs.WithContext(ctx).UnderlyingDB().
Table("activity_draw_logs").
Select(`
SUM(COALESCE(products.cost_price, 0) * (
COALESCE(NULLIF(activity_reward_settings.drop_quantity, 0), 1) +
CASE WHEN user_item_cards.used_draw_log_id = activity_draw_logs.id AND system_item_cards.effect_type = 1 AND system_item_cards.reward_multiplier_x1000 >= 2000 THEN 1 ELSE 0 END
)) AS cost_cents,
orders.paid_at
`).
Joins("JOIN orders ON orders.id = activity_draw_logs.order_id").
Joins("JOIN users ON users.id = orders.user_id").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id").
Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_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("users.channel_id = ? AND users.deleted_at IS NULL", channelID).
Where("orders.status = 2").
Where("orders.source_type IN ?", []int{2, 3, 4}).
Where("orders.ext_order_id = '' OR orders.ext_order_id IS NULL")
if startDate != nil && endDate != nil {
q = q.Where("orders.paid_at >= ? AND orders.paid_at <= ?", *startDate, *endDate)
}
var rows []costRow
q.Group("orders.id, orders.paid_at").Scan(&rows)
var total int64
byDate := make(map[string]int64)
for _, r := range rows {
total += r.CostCents
byDate[r.PaidAt.Format(dateFmt)] += r.CostCents
}
return total, byDate
}
func (s *service) Create(ctx context.Context, in CreateInput) (*model.Channels, error) {
m := &model.Channels{Name: in.Name, Code: in.Code, Type: in.Type, Remarks: in.Remarks}
if err := s.writeDB.Channels.WithContext(ctx).Create(m); err != nil {
@ -346,6 +396,46 @@ func (s *service) List(ctx context.Context, in ListInput) (items []*ChannelWithS
return
}
func (s *service) resolveStatsRange(ctx context.Context, channelID int64, days int, startDateStr, endDateStr string, now time.Time) (*time.Time, *time.Time, error) {
if startDateStr != "" && endDateStr != "" {
startDate, err := time.ParseInLocation("2006-01-02", startDateStr, time.Local)
if err != nil {
return nil, nil, err
}
endDate, err := time.ParseInLocation("2006-01-02", endDateStr, time.Local)
if err != nil {
return nil, nil, err
}
endDate = endDate.Add(24*time.Hour - time.Second)
return &startDate, &endDate, nil
}
if days > 0 {
startDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -days+1)
endDate := now
return &startDate, &endDate, nil
}
type minTimeRow struct {
MinCreatedAt *time.Time `gorm:"column:min_created_at"`
}
var row minTimeRow
err := s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
Joins("JOIN users ON users.id = orders.user_id").
Select("MIN(orders.created_at) as min_created_at").
Where("users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)", channelID).
Scan(&row).Error
if err != nil {
return nil, nil, err
}
if row.MinCreatedAt == nil {
return nil, nil, nil
}
startDate := time.Date(row.MinCreatedAt.Year(), row.MinCreatedAt.Month(), row.MinCreatedAt.Day(), 0, 0, 0, 0, now.Location())
endDate := now
return &startDate, &endDate, nil
}
func (s *service) GetStats(ctx context.Context, channelID int64, days int, startDateStr, endDateStr string) (*StatsOutput, error) {
now := time.Now()
@ -361,49 +451,53 @@ func (s *service) GetStats(ctx context.Context, channelID int64, days int, start
// source_type: 2=小程序抽奖 3=对对碰 4=一番赏/次卡 5=直播间抽奖抖店(不计入);排除商城直购(1)
// actual_amount>0 排除次卡免费使用的订单避免与购买次卡的订单重复计入GMV
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
statsStart, statsEnd, err := s.resolveStatsRange(ctx, channelID, days, startDateStr, endDateStr, now)
if err != nil {
return nil, err
}
// ========== 1. Overview全量不限时间==========
// ========== 1. Overview与当前筛选窗口保持一致==========
userCount, _ := s.readDB.Users.WithContext(ctx).Where(s.readDB.Users.ChannelID.Eq(channelID)).Count()
userCountQuery := s.readDB.Users.WithContext(ctx)
if statsStart != nil && statsEnd != nil {
userCountQuery = userCountQuery.Where(s.readDB.Users.CreatedAt.Gte(*statsStart)).Where(s.readDB.Users.CreatedAt.Lte(*statsEnd))
}
userCount, _ := userCountQuery.Where(s.readDB.Users.ChannelID.Eq(channelID)).Count()
out.Overview.TotalUsers = userCount
type countResult struct{ Count int64 }
var cr countResult
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
overviewOrders := s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
Joins("JOIN users ON users.id = orders.user_id").
Select("count(*) as count").
Where(orderFilter, channelID).
Scan(&cr)
Where(orderFilter, channelID)
if statsStart != nil && statsEnd != nil {
overviewOrders = overviewOrders.Where("orders.created_at >= ? AND orders.created_at <= ?", *statsStart, *statsEnd)
}
overviewOrders.Scan(&cr)
out.Overview.TotalOrders = cr.Count
totalGMV, _ := s.calcGMVByTotalAmount(ctx, channelID, "2006-01-02", orderFilter, nil, nil)
totalGMV, _ := s.calcGMVByTotalAmount(ctx, channelID, "2006-01-02", orderFilter, statsStart, statsEnd)
out.Overview.TotalPaidCents = totalGMV.Total
out.Overview.TotalGMV = totalGMV.Total / 100
out.Overview.CashCents = totalGMV.Cash
out.Overview.CouponCents = totalGMV.Coupon
out.Overview.PointsCents = totalGMV.Points
// 1d. 累计成本(全量,含道具卡倍数
totalCost, _ := s.calcCostByInventory(ctx, channelID, "2006-01-02", nil, nil)
// 1d. 累计成本(当前筛选窗口,按原始订单/抽奖来源归因
totalCost, _ := s.calcCostByDrawSource(ctx, channelID, "2006-01-02", statsStart, statsEnd)
out.Overview.TotalCostCents = totalCost
out.Overview.TotalCost = totalCost / 100
out.Overview.TotalProfitCents = totalGMV.Total - totalCost
out.Overview.TotalProfit = out.Overview.TotalProfitCents / 100
// ========== 2. 趋势图(按天分组,受 days 限制==========
// ========== 2. 趋势图(按天分组,与 overview 保持相同时间窗口==========
var startDate, endDate time.Time
if startDateStr != "" && endDateStr != "" {
startDate, _ = time.Parse("2006-01-02", startDateStr)
endDate, _ = time.Parse("2006-01-02", endDateStr)
endDate = endDate.Add(24*time.Hour - time.Second)
} else {
if days <= 0 {
days = 12
}
startDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -days+1)
endDate = now
if statsStart == nil || statsEnd == nil {
return out, nil
}
startDate := *statsStart
endDate := *statsEnd
dateMap := make(map[string]*StatsDailyItem)
var dateList []string
@ -452,8 +546,8 @@ func (s *service) GetStats(ctx context.Context, channelID int64, days int, start
}
}
// 2f. 每日成本(含道具卡倍数
_, dailyCost := s.calcCostByInventory(ctx, channelID, "2006-01-02", &startDate, &endDate)
// 2f. 每日成本(按原始订单/抽奖来源归因
_, dailyCost := s.calcCostByDrawSource(ctx, channelID, "2006-01-02", &startDate, &endDate)
for dateKey, cost := range dailyCost {
if item, ok := dateMap[dateKey]; ok {
item.CostCents = cost

View File

@ -58,6 +58,7 @@ func setupTestService(t *testing.T) (*service, mysql.Repo) {
ext_order_id TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
item_card_id INTEGER DEFAULT 0,
paid_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE user_inventory (
@ -73,18 +74,29 @@ func setupTestService(t *testing.T) (*service, mysql.Repo) {
)`,
`CREATE TABLE activity_reward_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
price_snapshot_cents INTEGER DEFAULT 0
product_id INTEGER DEFAULT 0,
price_snapshot_cents INTEGER DEFAULT 0,
drop_quantity INTEGER DEFAULT 1
)`,
`CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
price INTEGER DEFAULT 0
price INTEGER DEFAULT 0,
cost_price INTEGER DEFAULT 0
)`,
`CREATE TABLE activity_draw_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER DEFAULT 0,
reward_id INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE user_item_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
card_id INTEGER DEFAULT 0
card_id INTEGER DEFAULT 0,
used_draw_log_id INTEGER DEFAULT 0
)`,
`CREATE TABLE system_item_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
effect_type INTEGER DEFAULT 0,
reward_multiplier_x1000 INTEGER DEFAULT 1000
)`,
}
@ -120,32 +132,22 @@ func TestCalcGMVByTotalAmount_ThreeGameTypes(t *testing.T) {
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
// 抽奖订单 source=2含优惠券200
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 2, 800, 1000, 200, 0, 2, '', 'lottery:activity:10|count:1')`)
// 对对碰付费 source=3
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 2, 500, 500, 0, 0, 3, '', 'matching_game:issue:50')`)
// 一番赏 source=4含优惠券300
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 2, 500, 800, 300, 0, 4, '', 'game_pass_package:幸运|pkg_id:7|count:2')`)
// 次卡免费使用actual_amount=0 但 total_amount=600不应计入GMV避免与购买次卡重复计数
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 2, 0, 600, 0, 0, 2, '', 'lottery:activity:10|count:1|use_game_pass')`)
// 过滤条件status!=2不应计入
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 1, 9999, 9999, 0, 0, 2, '', 'lottery:activity:10|count:1')`)
// 过滤条件total_amount=0不应计入
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 2, 0, 0, 0, 0, 2, '', 'lottery:activity:10|count:1')`)
// 过滤条件:有 ext_order_id不应计入
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 2, 9999, 9999, 0, 0, 2, 'EXT-1', 'lottery:activity:10|count:1')`)
total, byDate := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil)
// GMV = 1000 + 500 + 800 = 2300次卡免费使用actual=0的600不计入
if total.Total != 2300 {
t.Errorf("total.Total = %d, want 2300 (抽奖1000 + 对对碰500 + 一番赏800)", total.Total)
}
// 现金 = 800 + 500 + 500 = 1800
if total.Cash != 1800 {
t.Errorf("total.Cash = %d, want 1800", total.Cash)
}
// 优惠券 = 200 + 300 = 500
if total.Coupon != 500 {
t.Errorf("total.Coupon = %d, want 500", total.Coupon)
}
@ -157,7 +159,6 @@ func TestCalcGMVByTotalAmount_ThreeGameTypes(t *testing.T) {
}
}
// TestCalcGMVByTotalAmount_DateFilter 验证时间范围过滤正确。
func TestCalcGMVByTotalAmount_DateFilter(t *testing.T) {
svc, repo := setupTestService(t)
@ -175,20 +176,14 @@ func TestCalcGMVByTotalAmount_DateFilter(t *testing.T) {
end = end.Add(24*time.Hour - time.Second)
total, byDate := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, &start, &end)
// 只有 03-05 的 300 在范围内
if total.Total != 300 {
t.Errorf("total.Total = %d, want 300 (only 2026-03-05 order)", total.Total)
}
if byDate["2026-03-05"].Total != 300 {
t.Errorf("byDate[2026-03-05].Total = %d, want 300", byDate["2026-03-05"].Total)
}
if byDate["2026-03-01"].Total != 0 && byDate["2026-03-10"].Total != 0 {
t.Error("dates outside range should not appear")
}
}
// TestCalcGMVByTotalAmount_MultiChannel 验证不同渠道数据互不干扰。
func TestCalcGMVByTotalAmount_MultiChannel(t *testing.T) {
svc, repo := setupTestService(t)
@ -212,93 +207,72 @@ func TestCalcGMVByTotalAmount_MultiChannel(t *testing.T) {
}
}
// TestCalcCostByInventory_Basic 验证成本从 value_cents 读取。
func TestCalcCostByInventory_Basic(t *testing.T) {
func TestCalcCostByDrawSource_TransferSafe(t *testing.T) {
svc, repo := setupTestService(t)
mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '渠道A', 'CA', 'other', ''), (2, '渠道B', 'CB', 'other', '')`)
mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`)
mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (2, 'u2', 'I2', 1, 2)`)
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, paid_at, created_at) VALUES (10, 1, 2, 1000, 1000, 2, '', '2026-03-05 10:00:00', '2026-03-05 10:00:00')`)
mustExec(t, repo, `INSERT INTO products (id, price, cost_price) VALUES (100, 1500, 900)`)
mustExec(t, repo, `INSERT INTO activity_reward_settings (id, product_id, price_snapshot_cents, drop_quantity) VALUES (200, 100, 0, 1)`)
mustExec(t, repo, `INSERT INTO activity_draw_logs (id, order_id, reward_id, created_at) VALUES (300, 10, 200, '2026-03-05 10:00:00')`)
totalA, byDateA := svc.calcCostByDrawSource(context.Background(), 1, "2006-01-02", nil, nil)
totalB, _ := svc.calcCostByDrawSource(context.Background(), 2, "2006-01-02", nil, nil)
if totalA != 900 {
t.Errorf("channel A cost = %d, want 900", totalA)
}
if totalB != 0 {
t.Errorf("channel B cost = %d, want 0", totalB)
}
if byDateA["2026-03-05"] != 900 {
t.Errorf("channel A cost on 2026-03-05 = %d, want 900", byDateA["2026-03-05"])
}
}
func TestCalcCostByDrawSource_DoubleRewardAndDropQuantity(t *testing.T) {
svc, repo := setupTestService(t)
mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '测试渠道', 'TEST', 'other', '')`)
mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`)
mustExec(t, repo, `INSERT INTO system_item_cards (id, effect_type, reward_multiplier_x1000) VALUES (1, 1, 2000)`)
mustExec(t, repo, `INSERT INTO user_item_cards (id, card_id, used_draw_log_id) VALUES (1, 1, 300)`)
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, item_card_id, paid_at, created_at) VALUES (10, 1, 2, 1000, 1000, 2, '', 1, '2026-03-05 10:00:00', '2026-03-05 10:00:00')`)
mustExec(t, repo, `INSERT INTO products (id, price, cost_price) VALUES (100, 1500, 500)`)
mustExec(t, repo, `INSERT INTO activity_reward_settings (id, product_id, price_snapshot_cents, drop_quantity) VALUES (200, 100, 0, 2)`)
mustExec(t, repo, `INSERT INTO activity_draw_logs (id, order_id, reward_id, created_at) VALUES (300, 10, 200, '2026-03-05 10:00:00')`)
// status=1(待发货) 和 status=3(已发货) 都计入成本
mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 1, 500, '')`)
mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 3, 300, '')`)
// status=2 不计入
mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 2, 999, '')`)
// remark含void 不计入
mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 1, 888, 'void-item')`)
total, byDate := svc.calcCostByInventory(context.Background(), 1, "2006-01-02", nil, nil)
// 500 + 300 = 800
if total != 800 {
t.Errorf("cost total = %d, want 800", total)
total, byDate := svc.calcCostByDrawSource(context.Background(), 1, "2006-01-02", nil, nil)
if total != 1500 {
t.Errorf("cost total = %d, want 1500", total)
}
if len(byDate) == 0 {
t.Error("byDate should not be empty")
if byDate["2026-03-05"] != 1500 {
t.Errorf("cost on 2026-03-05 = %d, want 1500", byDate["2026-03-05"])
}
}
// TestProfitLoss_AllGameTypes 端到端验证盈亏 = GMV(原价) - 成本,覆盖三种游戏类型及道具卡免单。
// 核心场景:道具卡免单订单 actual_amount=0 但 total_amount=活动原价,成本真实存在,
// 使用 total_amount 口径确保盈亏计算准确。
func TestProfitLoss_AllGameTypes(t *testing.T) {
func TestCalcCostByDrawSource_DateFilterUsesOrderDate(t *testing.T) {
svc, repo := setupTestService(t)
mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '测试渠道', 'TEST', 'other', '')`)
mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`)
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, paid_at, created_at) VALUES (10, 1, 2, 1000, 1000, 2, '', '2026-03-05 10:00:00', '2026-03-05 10:00:00')`)
mustExec(t, repo, `INSERT INTO products (id, price, cost_price) VALUES (100, 1500, 700)`)
mustExec(t, repo, `INSERT INTO activity_reward_settings (id, product_id, price_snapshot_cents, drop_quantity) VALUES (200, 100, 0, 1)`)
mustExec(t, repo, `INSERT INTO activity_draw_logs (id, order_id, reward_id, created_at) VALUES (300, 10, 200, '2026-03-09 10:00:00')`)
mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, reward_id, product_id, status, value_cents, remark, created_at) VALUES (1, 10, 200, 100, 1, 1500, '', '2026-03-09 10:00:00')`)
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
start, _ := time.Parse("2006-01-02", "2026-03-05")
end, _ := time.Parse("2006-01-02", "2026-03-05")
end = end.Add(24*time.Hour - time.Second)
// 收入3种游戏total_amount = 活动原价),含优惠券拆分
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (1, 1, 2, 3600, 4600, 1000, 0, 2, '', 'lottery:activity:10|count:1')`) // 抽奖 46元(现金36+券10)
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (2, 1, 2, 1086, 1086, 0, 0, 3, '', 'matching_game:issue:50')`) // 对对碰 10.86元(全现金)
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (3, 1, 2, 2820, 3320, 500, 0, 4, '', 'game_pass_package:x|pkg_id:7|count:2')`) // 一番赏 33.20元(现金28.20+券5)
// 次卡免费使用actual_amount=0total_amount=2000不计入GMV避免重复计数但成本仍计入
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, discount_amount, points_amount, source_type, ext_order_id, remark) VALUES (4, 1, 2, 0, 2000, 0, 0, 2, '', 'lottery:activity:10|count:1|use_game_pass')`)
// 成本:库存资产
mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 1, 8000, '')`) // 成本 80元
totalGMV, _ := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil)
totalCost, _ := svc.calcCostByInventory(context.Background(), 1, "2006-01-02", nil, nil)
profit := totalGMV.Total - totalCost
// GMV = 4600 + 1086 + 3320 = 9006次卡免费使用的2000不计入
if totalGMV.Total != 9006 {
t.Errorf("totalGMV.Total = %d, want 9006 (抽奖4600 + 对对碰1086 + 一番赏3320)", totalGMV.Total)
total, byDate := svc.calcCostByDrawSource(context.Background(), 1, "2006-01-02", &start, &end)
if total != 700 {
t.Errorf("cost total = %d, want 700", total)
}
// 现金 = 3600 + 1086 + 2820 = 7506
if totalGMV.Cash != 7506 {
t.Errorf("totalGMV.Cash = %d, want 7506", totalGMV.Cash)
}
// 优惠券 = 1000 + 500 = 1500
if totalGMV.Coupon != 1500 {
t.Errorf("totalGMV.Coupon = %d, want 1500", totalGMV.Coupon)
}
// 成本 = 8000
if totalCost != 8000 {
t.Errorf("totalCost = %d, want 8000", totalCost)
}
// 盈亏 = 9006 - 8000 = 1006
if profit != 1006 {
t.Errorf("profit = %d, want 1006", profit)
}
}
// TestCalcGMVByTotalAmount_Empty 验证无订单时返回零值。
func TestCalcGMVByTotalAmount_Empty(t *testing.T) {
svc, repo := setupTestService(t)
mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '空渠道', 'EMPTY', 'other', '')`)
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
total, byDate := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil)
if total.Total != 0 {
t.Errorf("empty channel total = %d, want 0", total.Total)
}
if len(byDate) != 0 {
t.Errorf("byDate should be empty, got %v", byDate)
if byDate["2026-03-05"] != 700 {
t.Errorf("cost on 2026-03-05 = %d, want 700", byDate["2026-03-05"])
}
}