diff --git a/internal/service/channel/channel.go b/internal/service/channel/channel.go index e2a5b90..d843a01 100755 --- a/internal/service/channel/channel.go +++ b/internal/service/channel/channel.go @@ -77,6 +77,9 @@ type StatsOverview struct { TotalProfitCents int64 `json:"total_profit_cents"` // 盈亏(分) = paid - cost TotalCost int64 `json:"total_cost"` // 总成本(元) TotalProfit int64 `json:"total_profit"` // 盈亏(元) + CashCents int64 `json:"cash_cents"` // 现金支付(分) + CouponCents int64 `json:"coupon_cents"` // 优惠券抵扣(分) + PointsCents int64 `json:"points_cents"` // 积分抵扣(分) } type StatsDailyItem struct { @@ -87,6 +90,9 @@ type StatsDailyItem struct { PaidCents int64 `json:"paid_cents"` CostCents int64 `json:"cost_cents"` // 当日成本(分) ProfitCents int64 `json:"profit_cents"` // 当日盈亏(分) + CashCents int64 `json:"cash_cents"` // 当日现金(分) + CouponCents int64 `json:"coupon_cents"` // 当日优惠券(分) + PointsCents int64 `json:"points_cents"` // 当日积分(分) } type SearchUsersInput struct { @@ -133,18 +139,28 @@ type orderAmountRow struct { CreatedAt time.Time } -// calcGMVByTotalAmount 按订单原价(total_amount)统计渠道GMV,涵盖全部游戏类型(抽奖、对对碰、一番赏)。 -// 使用 total_amount(活动原价)而非 actual_amount,确保优惠券、道具卡、积分抵扣的订单也完整计入, -// 与成本(商品价值)保持同一口径,使盈亏计算真实反映业务健康度。 -// 返回:总金额(分)、按 dateFmt 格式分组的金额。 -func (s *service) calcGMVByTotalAmount(ctx context.Context, channelID int64, dateFmt string, orderFilter string, startDate, endDate *time.Time) (int64, map[string]int64) { +// GMVBreakdown GMV 支付方式拆分 +type GMVBreakdown struct { + Total int64 // total_amount 合计(分) + Cash int64 // actual_amount 现金(分) + Coupon int64 // discount_amount 优惠券(分) + Points int64 // points_amount 积分(分) +} + +// calcGMVByTotalAmount 按订单原价(total_amount)统计渠道GMV,同时拆分支付方式。 +// total_amount = actual_amount(现金) + discount_amount(优惠券) + points_amount(积分) +// 返回:总拆分、按 dateFmt 格式分组的拆分。 +func (s *service) calcGMVByTotalAmount(ctx context.Context, channelID int64, dateFmt string, orderFilter string, startDate, endDate *time.Time) (GMVBreakdown, map[string]GMVBreakdown) { type row struct { - TotalAmount int64 - CreatedAt time.Time + TotalAmount int64 + ActualAmount int64 + DiscountAmount int64 + PointsAmount int64 + CreatedAt time.Time } q := s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders"). Joins("JOIN users ON users.id = orders.user_id"). - Select("orders.total_amount, orders.created_at"). + Select("orders.total_amount, orders.actual_amount, orders.discount_amount, orders.points_amount, orders.created_at"). Where(orderFilter, channelID) if startDate != nil && endDate != nil { q = q.Where("orders.created_at >= ? AND orders.created_at <= ?", *startDate, *endDate) @@ -152,11 +168,20 @@ func (s *service) calcGMVByTotalAmount(ctx context.Context, channelID int64, dat var rows []row q.Scan(&rows) - var total int64 - byDate := make(map[string]int64) + var total GMVBreakdown + byDate := make(map[string]GMVBreakdown) for _, r := range rows { - total += r.TotalAmount - byDate[r.CreatedAt.Format(dateFmt)] += r.TotalAmount + total.Total += r.TotalAmount + total.Cash += r.ActualAmount + total.Coupon += r.DiscountAmount + total.Points += r.PointsAmount + key := r.CreatedAt.Format(dateFmt) + d := byDate[key] + d.Total += r.TotalAmount + d.Cash += r.ActualAmount + d.Coupon += r.DiscountAmount + d.Points += r.PointsAmount + byDate[key] = d } return total, byDate } @@ -351,15 +376,18 @@ func (s *service) GetStats(ctx context.Context, channelID int64, days int, start Scan(&cr) out.Overview.TotalOrders = cr.Count - totalPaid, _ := s.calcGMVByTotalAmount(ctx, channelID, "2006-01-02", orderFilter, nil, nil) - out.Overview.TotalPaidCents = totalPaid - out.Overview.TotalGMV = totalPaid / 100 + 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.calcCostByInventory(ctx, channelID, "2006-01-02", nil, nil) out.Overview.TotalCostCents = totalCost out.Overview.TotalCost = totalCost / 100 - out.Overview.TotalProfitCents = totalPaid - totalCost + out.Overview.TotalProfitCents = totalGMV.Total - totalCost out.Overview.TotalProfit = out.Overview.TotalProfitCents / 100 // ========== 2. 趋势图(按天分组,受 days 限制)========== @@ -416,8 +444,11 @@ func (s *service) GetStats(ctx context.Context, channelID int64, days int, start _, dailyPaid := s.calcGMVByTotalAmount(ctx, channelID, "2006-01-02", orderFilter, &startDate, &endDate) for dateKey, paid := range dailyPaid { if item, ok := dateMap[dateKey]; ok { - item.PaidCents = paid - item.GMV = paid / 100 + item.PaidCents = paid.Total + item.GMV = paid.Total / 100 + item.CashCents = paid.Cash + item.CouponCents = paid.Coupon + item.PointsCents = paid.Points } } diff --git a/internal/service/channel/channel_stats_test.go b/internal/service/channel/channel_stats_test.go index 1230d8d..15f2038 100644 --- a/internal/service/channel/channel_stats_test.go +++ b/internal/service/channel/channel_stats_test.go @@ -52,6 +52,8 @@ func setupTestService(t *testing.T) (*service, mysql.Repo) { status INTEGER NOT NULL, actual_amount INTEGER NOT NULL DEFAULT 0, total_amount INTEGER NOT NULL DEFAULT 0, + discount_amount INTEGER NOT NULL DEFAULT 0, + points_amount INTEGER NOT NULL DEFAULT 0, source_type INTEGER NOT NULL DEFAULT 1, ext_order_id TEXT NOT NULL DEFAULT '', remark TEXT NOT NULL DEFAULT '', @@ -118,26 +120,37 @@ 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,actual_amount < total_amount(道具卡折扣) - mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 800, 1000, 2, '', 'lottery:activity:10|count:1')`) + // 抽奖订单 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, source_type, ext_order_id, remark) VALUES (1, 2, 500, 500, 3, '', 'matching_game:issue:50')`) - // 一番赏 source=4 - mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 800, 800, 4, '', 'game_pass_package:幸运|pkg_id:7|count: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, 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, source_type, ext_order_id, remark) VALUES (1, 2, 0, 600, 2, '', 'lottery:activity:10|count:1|use_game_pass')`) + 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, source_type, ext_order_id, remark) VALUES (1, 1, 9999, 9999, 2, '', 'lottery:activity:10|count:1')`) + 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, source_type, ext_order_id, remark) VALUES (1, 2, 0, 0, 2, '', 'lottery:activity:10|count:1')`) + 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, source_type, ext_order_id, remark) VALUES (1, 2, 9999, 9999, 2, 'EXT-1', 'lottery:activity:10|count:1')`) + 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) - // 1000 + 500 + 800 = 2300(次卡免费使用actual=0的600不计入) - if total != 2300 { - t.Errorf("total = %d, want 2300 (抽奖1000 + 对对碰500 + 一番赏800)", total) + // 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) + } + if total.Points != 0 { + t.Errorf("total.Points = %d, want 0", total.Points) } if len(byDate) == 0 { t.Error("byDate should not be empty") @@ -164,13 +177,13 @@ func TestCalcGMVByTotalAmount_DateFilter(t *testing.T) { total, byDate := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, &start, &end) // 只有 03-05 的 300 在范围内 - if total != 300 { - t.Errorf("total = %d, want 300 (only 2026-03-05 order)", total) + if total.Total != 300 { + t.Errorf("total.Total = %d, want 300 (only 2026-03-05 order)", total.Total) } - if byDate["2026-03-05"] != 300 { - t.Errorf("byDate[2026-03-05] = %d, want 300", byDate["2026-03-05"]) + 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"] != 0 && byDate["2026-03-10"] != 0 { + if byDate["2026-03-01"].Total != 0 && byDate["2026-03-10"].Total != 0 { t.Error("dates outside range should not appear") } } @@ -191,11 +204,11 @@ func TestCalcGMVByTotalAmount_MultiChannel(t *testing.T) { total1, _ := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil) total2, _ := svc.calcGMVByTotalAmount(context.Background(), 2, "2006-01-02", orderFilter, nil, nil) - if total1 != 1000 { - t.Errorf("channel1 total = %d, want 1000", total1) + if total1.Total != 1000 { + t.Errorf("channel1 total = %d, want 1000", total1.Total) } - if total2 != 2000 { - t.Errorf("channel2 total = %d, want 2000", total2) + if total2.Total != 2000 { + t.Errorf("channel2 total = %d, want 2000", total2.Total) } } @@ -236,23 +249,31 @@ func TestProfitLoss_AllGameTypes(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)" - // 收入:3种游戏(total_amount = 活动原价) - mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 1, 2, 4600, 4600, 2, '', 'lottery:activity:10|count:1')`) // 抽奖 46元 - mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (2, 1, 2, 1086, 1086, 3, '', 'matching_game:issue:50')`) // 对对碰 10.86元 - mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (3, 1, 2, 3320, 3320, 4, '', 'game_pass_package:x|pkg_id:7|count:2')`) // 一番赏 33.20元 + // 收入: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, source_type, ext_order_id, remark) VALUES (4, 1, 2, 0, 2000, 2, '', 'lottery:activity:10|count:1|use_game_pass')`) + 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 - totalCost + profit := totalGMV.Total - totalCost // GMV = 4600 + 1086 + 3320 = 9006(次卡免费使用的2000不计入) - if totalGMV != 9006 { - t.Errorf("totalGMV = %d, want 9006 (抽奖4600 + 对对碰1086 + 一番赏3320)", totalGMV) + if totalGMV.Total != 9006 { + t.Errorf("totalGMV.Total = %d, want 9006 (抽奖4600 + 对对碰1086 + 一番赏3320)", totalGMV.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 { @@ -274,8 +295,8 @@ func TestCalcGMVByTotalAmount_Empty(t *testing.T) { total, byDate := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil) - if total != 0 { - t.Errorf("empty channel total = %d, want 0", total) + 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) diff --git a/web/admin b/web/admin index 212aacb..10c445d 160000 --- a/web/admin +++ b/web/admin @@ -1 +1 @@ -Subproject commit 212aacb5f8dac05b214a4c19eed4395ed7922943 +Subproject commit 10c445d1ed670c74e9be1de25dad22c89a66c29b