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:
parent
8d1eef2f7f
commit
c0267c7a33
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 212aacb5f8dac05b214a4c19eed4395ed7922943
|
||||
Subproject commit 10c445d1ed670c74e9be1de25dad22c89a66c29b
|
||||
Loading…
x
Reference in New Issue
Block a user