feat(channel): GMV支付方式拆分(现金/优惠券/积分)

后端:
- GMVBreakdown 结构体拆分 total/cash/coupon/points 四个维度
- calcGMVByTotalAmount 返回值改为 GMVBreakdown,SELECT 增加 actual_amount/discount_amount/points_amount
- StatsOverview/StatsDailyItem 新增 cash_cents/coupon_cents/points_cents 字段
- 测试数据增加优惠券场景,新增拆分断言

前端:
- API 接口类型同步增加 3 个拆分字段
- 概览区新增支付构成行:现金/优惠券/积分 各带金额和占比
This commit is contained in:
win 2026-03-16 23:33:48 +08:00
parent 8d1eef2f7f
commit c0267c7a33
3 changed files with 102 additions and 50 deletions

View File

@ -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
}
}

View File

@ -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=2actual_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=0total_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)

@ -1 +1 @@
Subproject commit 212aacb5f8dac05b214a4c19eed4395ed7922943
Subproject commit 10c445d1ed670c74e9be1de25dad22c89a66c29b