Compare commits
No commits in common. "fb4b266bacd906f5994cf18a43fbcafc7563bdb5" and "c7a6e1e017ad3557417ffb21685d9582cfcb191f" have entirely different histories.
fb4b266bac
...
c7a6e1e017
@ -234,56 +234,6 @@ 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 {
|
||||
@ -396,46 +346,6 @@ 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()
|
||||
|
||||
@ -451,53 +361,49 @@ 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(全量,不限时间)==========
|
||||
|
||||
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()
|
||||
userCount, _ := s.readDB.Users.WithContext(ctx).Where(s.readDB.Users.ChannelID.Eq(channelID)).Count()
|
||||
out.Overview.TotalUsers = userCount
|
||||
|
||||
type countResult struct{ Count int64 }
|
||||
var cr countResult
|
||||
overviewOrders := s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||||
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("count(*) as count").
|
||||
Where(orderFilter, channelID)
|
||||
if statsStart != nil && statsEnd != nil {
|
||||
overviewOrders = overviewOrders.Where("orders.created_at >= ? AND orders.created_at <= ?", *statsStart, *statsEnd)
|
||||
}
|
||||
overviewOrders.Scan(&cr)
|
||||
Where(orderFilter, channelID).
|
||||
Scan(&cr)
|
||||
out.Overview.TotalOrders = cr.Count
|
||||
|
||||
totalGMV, _ := s.calcGMVByTotalAmount(ctx, channelID, "2006-01-02", orderFilter, statsStart, statsEnd)
|
||||
totalGMV, _ := s.calcGMVByTotalAmount(ctx, channelID, "2006-01-02", orderFilter, nil, nil)
|
||||
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.calcCostByDrawSource(ctx, channelID, "2006-01-02", statsStart, statsEnd)
|
||||
// 1d. 累计成本(全量,含道具卡倍数)
|
||||
totalCost, _ := s.calcCostByInventory(ctx, channelID, "2006-01-02", nil, nil)
|
||||
out.Overview.TotalCostCents = totalCost
|
||||
out.Overview.TotalCost = totalCost / 100
|
||||
out.Overview.TotalProfitCents = totalGMV.Total - totalCost
|
||||
out.Overview.TotalProfit = out.Overview.TotalProfitCents / 100
|
||||
|
||||
// ========== 2. 趋势图(按天分组,与 overview 保持相同时间窗口)==========
|
||||
// ========== 2. 趋势图(按天分组,受 days 限制)==========
|
||||
|
||||
if statsStart == nil || statsEnd == nil {
|
||||
return out, nil
|
||||
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
|
||||
}
|
||||
startDate := *statsStart
|
||||
endDate := *statsEnd
|
||||
|
||||
dateMap := make(map[string]*StatsDailyItem)
|
||||
var dateList []string
|
||||
@ -546,8 +452,8 @@ func (s *service) GetStats(ctx context.Context, channelID int64, days int, start
|
||||
}
|
||||
}
|
||||
|
||||
// 2f. 每日成本(按原始订单/抽奖来源归因)
|
||||
_, dailyCost := s.calcCostByDrawSource(ctx, channelID, "2006-01-02", &startDate, &endDate)
|
||||
// 2f. 每日成本(含道具卡倍数)
|
||||
_, dailyCost := s.calcCostByInventory(ctx, channelID, "2006-01-02", &startDate, &endDate)
|
||||
for dateKey, cost := range dailyCost {
|
||||
if item, ok := dateMap[dateKey]; ok {
|
||||
item.CostCents = cost
|
||||
|
||||
@ -58,7 +58,6 @@ 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 (
|
||||
@ -74,29 +73,18 @@ func setupTestService(t *testing.T) (*service, mysql.Repo) {
|
||||
)`,
|
||||
`CREATE TABLE activity_reward_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id INTEGER DEFAULT 0,
|
||||
price_snapshot_cents INTEGER DEFAULT 0,
|
||||
drop_quantity INTEGER DEFAULT 1
|
||||
price_snapshot_cents INTEGER DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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
|
||||
price INTEGER DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE user_item_cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
card_id INTEGER DEFAULT 0,
|
||||
used_draw_log_id INTEGER DEFAULT 0
|
||||
card_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
|
||||
)`,
|
||||
}
|
||||
@ -132,22 +120,32 @@ 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)
|
||||
}
|
||||
@ -159,6 +157,7 @@ func TestCalcGMVByTotalAmount_ThreeGameTypes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcGMVByTotalAmount_DateFilter 验证时间范围过滤正确。
|
||||
func TestCalcGMVByTotalAmount_DateFilter(t *testing.T) {
|
||||
svc, repo := setupTestService(t)
|
||||
|
||||
@ -176,14 +175,20 @@ 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)
|
||||
|
||||
@ -207,72 +212,93 @@ func TestCalcGMVByTotalAmount_MultiChannel(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) {
|
||||
// TestCalcCostByInventory_Basic 验证成本从 value_cents 读取。
|
||||
func TestCalcCostByInventory_Basic(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')`)
|
||||
|
||||
total, byDate := svc.calcCostByDrawSource(context.Background(), 1, "2006-01-02", nil, nil)
|
||||
if total != 1500 {
|
||||
t.Errorf("cost total = %d, want 1500", total)
|
||||
// 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)
|
||||
}
|
||||
if byDate["2026-03-05"] != 1500 {
|
||||
t.Errorf("cost on 2026-03-05 = %d, want 1500", byDate["2026-03-05"])
|
||||
if len(byDate) == 0 {
|
||||
t.Error("byDate should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalcCostByDrawSource_DateFilterUsesOrderDate(t *testing.T) {
|
||||
// TestProfitLoss_AllGameTypes 端到端验证盈亏 = GMV(原价) - 成本,覆盖三种游戏类型及道具卡免单。
|
||||
// 核心场景:道具卡免单订单 actual_amount=0 但 total_amount=活动原价,成本真实存在,
|
||||
// 使用 total_amount 口径确保盈亏计算准确。
|
||||
func TestProfitLoss_AllGameTypes(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')`)
|
||||
|
||||
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)
|
||||
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.calcCostByDrawSource(context.Background(), 1, "2006-01-02", &start, &end)
|
||||
if total != 700 {
|
||||
t.Errorf("cost total = %d, want 700", total)
|
||||
// 收入: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=0,total_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)
|
||||
}
|
||||
if byDate["2026-03-05"] != 700 {
|
||||
t.Errorf("cost on 2026-03-05 = %d, want 700", byDate["2026-03-05"])
|
||||
// 现金 = 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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user