fix(livestream): 统一直播间盈亏成本口径并消除转赠影响

将直播间统计从基于 user_inventory 当前持有状态和 remark 反推成本,改为基于 livestream_draw_logs 中奖事实直接关联 products.cost_price 计算成本。统一 /livestream/activities/:id/stats 与 /livestream/activities/:id/draw_logs 两个接口的营收、退款、成本和净利润口径,避免因转赠、remark 覆盖或订单行缺失导致统计失真,并补充针对转赠、退款、零订单和 product 回退场景的回归测试。
This commit is contained in:
Zuncle 2026-04-12 21:23:36 +08:00
parent fb4b266bac
commit c927f46cdd
4 changed files with 789 additions and 440 deletions

View File

@ -3,10 +3,8 @@ package admin
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"bindbox-game/internal/code"
@ -783,38 +781,9 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
pageSize = 20
}
// 解析时间范围 (支持 YYYY-MM-DD HH:mm:ss 和 YYYY-MM-DD)
var startTime, endTime *time.Time
if req.StartTime != "" {
// 尝试解析完整时间
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.StartTime, time.Local); err == nil {
startTime = &t
} else if t, err := time.ParseInLocation("2006-01-02", req.StartTime, time.Local); err == nil {
// 只有日期,默认 00:00:00
startTime = &t
}
}
if req.EndTime != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.EndTime, time.Local); err == nil {
endTime = &t
} else if t, err := time.ParseInLocation("2006-01-02", req.EndTime, time.Local); err == nil {
// 只有日期,设为当天结束 23:59:59.999
end := t.Add(24*time.Hour - time.Nanosecond)
endTime = &end
}
}
startTime, endTime := parseLivestreamDateRange(req.StartTime, req.EndTime, true)
// 解析排除用户ID
var excludeUIDs []int64
if req.ExcludeUserIDs != "" {
parts := strings.Split(req.ExcludeUserIDs, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if val, err := strconv.ParseInt(p, 10, 64); err == nil && val > 0 {
excludeUIDs = append(excludeUIDs, val)
}
}
}
excludeUIDs := parseExcludeUserIDs(req.ExcludeUserIDs)
// 使用底层 GORM 直接查询以支持 keyword
db := h.repo.GetDbR().Model(&model.LivestreamDrawLogs{}).Where("activity_id = ?", activityID)
@ -839,135 +808,25 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
// 计算统计数据 (仅当有数据时)
var stats *livestreamDrawLogsStats
if total > 0 {
stats = &livestreamDrawLogsStats{}
// 1. 统计用户数
// 使用 Session() 避免污染主 db 对象
db.Session(&gorm.Session{}).Select("COUNT(DISTINCT douyin_user_id)").Scan(&stats.UserCount)
// 2. 获取所有相关的 douyin_order_id 和 prize_id用于在内存中聚合金额和成本
// 注意:如果数据量极大,这里可能有性能隐患。但考虑到这是后台查询且通常带有筛选,暂且全量拉取 ID。
// 优化:只查需要的字段
type logMeta struct {
DouyinOrderID int64
PrizeID int64
ShopOrderID string // 用于关联退款状态查 douyin_orders
LocalUserID int64
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{
ActivityID: activityID,
StartTime: startTime,
EndTime: endTime,
Keyword: req.Keyword,
ExcludeUserIDs: excludeUIDs,
}, ticketPrice)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
var metas []logMeta
// 使用不带分页的 db 克隆
if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id, local_user_id").Scan(&metas).Error; err == nil {
orderIDs := make([]int64, 0, len(metas))
distinctOrderIDs := make(map[int64]bool)
prizeIDCount := make(map[int64]int64)
for _, m := range metas {
if !distinctOrderIDs[m.DouyinOrderID] {
distinctOrderIDs[m.DouyinOrderID] = true
orderIDs = append(orderIDs, m.DouyinOrderID)
}
}
stats.OrderCount = int64(len(orderIDs))
// 3. 查询订单金额和退款状态
if len(orderIDs) > 0 {
var orders []model.DouyinOrders
// 分批查询防止 IN 子句过长? 暂时假设量级可控
h.repo.GetDbR().Select("id, actual_pay_amount, order_status, pay_type_desc, product_count").
Where("id IN ?", orderIDs).Find(&orders)
orderRefundMap := make(map[int64]bool)
for _, o := range orders {
// 统计营收 (总流水)
orderAmount := calcLivestreamOrderAmount(&o, ticketPrice)
stats.TotalRev += orderAmount
if o.OrderStatus == 4 { // 已退款
stats.TotalRefund += orderAmount
orderRefundMap[o.ID] = true
}
}
// 4. 统计成本 (剔除退款订单,优先资产快照,缺失回退 prize.cost_price)
for _, m := range metas {
if !orderRefundMap[m.DouyinOrderID] {
prizeIDCount[m.PrizeID]++
}
}
prizeCostMap := make(map[int64]int64)
if len(prizeIDCount) > 0 {
prizeIDs := make([]int64, 0, len(prizeIDCount))
for pid := range prizeIDCount {
prizeIDs = append(prizeIDs, pid)
}
var prizes []model.LivestreamPrizes
h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes)
for _, p := range prizes {
prizeCostMap[p.ID] = p.CostPrice
}
}
// 预加载用户资产快照用于 shop_order_id 命中
type invRow struct {
UserID int64
ValueCents int64
Remark string
}
var invRows []invRow
_ = h.repo.GetDbR().Table("user_inventory").
Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
Where("user_inventory.status IN (1,3)").
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
Where("user_inventory.user_id > 0").
Scan(&invRows).Error
invByUser := make(map[int64][]invRow)
for _, v := range invRows {
invByUser[v.UserID] = append(invByUser[v.UserID], v)
}
metasByKey := make(map[string][]logMeta)
keyUser := make(map[string]int64)
keyOrder := make(map[string]string)
for _, m := range metas {
if orderRefundMap[m.DouyinOrderID] {
continue
}
key := fmt.Sprintf("%d|%s", m.LocalUserID, m.ShopOrderID)
metasByKey[key] = append(metasByKey[key], m)
keyUser[key] = m.LocalUserID
keyOrder[key] = m.ShopOrderID
}
for key, rows := range metasByKey {
if len(rows) == 0 {
continue
}
uid := keyUser[key]
shopOrderID := keyOrder[key]
var snapshotSum int64
if uid > 0 && shopOrderID != "" {
for _, inv := range invByUser[uid] {
if strings.Contains(inv.Remark, shopOrderID) {
snapshotSum += inv.ValueCents
}
}
}
if snapshotSum > 0 {
stats.TotalCost += snapshotSum
continue
}
for _, r := range rows {
stats.TotalCost += prizeCostMap[r.PrizeID]
}
}
}
stats = &livestreamDrawLogsStats{
UserCount: metrics.UserCount,
OrderCount: metrics.OrderCount,
TotalRev: metrics.TotalRevenue,
TotalRefund: metrics.TotalRefund,
TotalCost: metrics.TotalCost,
NetProfit: metrics.NetProfit,
}
stats.NetProfit = (stats.TotalRev - stats.TotalRefund) - stats.TotalCost
}
var logs []model.LivestreamDrawLogs

View File

@ -0,0 +1,335 @@
package admin
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/gorm"
)
type livestreamMetricsFilter struct {
ActivityID int64
StartTime *time.Time
EndTime *time.Time
Keyword string
ExcludeUserIDs []int64
}
type livestreamMetrics struct {
UserCount int64
OrderCount int64
RefundCount int64
TotalRevenue int64
TotalRefund int64
TotalCost int64
NetProfit int64
ProfitMargin float64
Daily []dailyLivestreamStats
}
type livestreamOrderSummary struct {
UserCount int64
OrderCount int64
RefundCount int64
TotalRevenue int64
TotalRefund int64
RefundedOrderIDs map[int64]bool
RevenueByDate map[string]int64
RefundByDate map[string]int64
OrderCountByDate map[string]int64
RefundCountByDate map[string]int64
}
type livestreamCostSummary struct {
TotalCost int64
CostByDate map[string]int64
}
func (h *handler) buildLivestreamDrawLogScope(db *gorm.DB, f livestreamMetricsFilter) *gorm.DB {
q := db.Table("livestream_draw_logs AS dl").Where("dl.activity_id = ?", f.ActivityID)
if f.StartTime != nil {
q = q.Where("dl.created_at >= ?", f.StartTime)
}
if f.EndTime != nil {
q = q.Where("dl.created_at <= ?", f.EndTime)
}
if keyword := strings.TrimSpace(f.Keyword); keyword != "" {
kw := "%" + keyword + "%"
q = q.Where("(dl.user_nickname LIKE ? OR dl.shop_order_id LIKE ? OR dl.prize_name LIKE ?)", kw, kw, kw)
}
if len(f.ExcludeUserIDs) > 0 {
q = q.Where("dl.local_user_id NOT IN ?", f.ExcludeUserIDs)
}
return q
}
func (h *handler) buildLivestreamMetrics(f livestreamMetricsFilter, ticketPrice int64) (*livestreamMetrics, error) {
scope := h.buildLivestreamDrawLogScope(h.repo.GetDbR(), f)
orderSummary, err := h.loadLivestreamOrderSummary(scope, ticketPrice)
if err != nil {
return nil, err
}
costSummary, err := h.loadLivestreamCostSummary(scope, orderSummary.RefundedOrderIDs)
if err != nil {
return nil, err
}
dailyMap := make(map[string]*dailyLivestreamStats)
mergeDailyAmount := func(source map[string]int64, apply func(*dailyLivestreamStats, int64)) {
for dateKey, value := range source {
ds := dailyMap[dateKey]
if ds == nil {
ds = &dailyLivestreamStats{Date: dateKey}
dailyMap[dateKey] = ds
}
apply(ds, value)
}
}
mergeDailyCount := func(source map[string]int64, apply func(*dailyLivestreamStats, int64)) {
for dateKey, value := range source {
ds := dailyMap[dateKey]
if ds == nil {
ds = &dailyLivestreamStats{Date: dateKey}
dailyMap[dateKey] = ds
}
apply(ds, value)
}
}
mergeDailyAmount(orderSummary.RevenueByDate, func(ds *dailyLivestreamStats, v int64) { ds.TotalRevenue += v })
mergeDailyAmount(orderSummary.RefundByDate, func(ds *dailyLivestreamStats, v int64) { ds.TotalRefund += v })
mergeDailyAmount(costSummary.CostByDate, func(ds *dailyLivestreamStats, v int64) { ds.TotalCost += v })
mergeDailyCount(orderSummary.OrderCountByDate, func(ds *dailyLivestreamStats, v int64) { ds.OrderCount += v })
mergeDailyCount(orderSummary.RefundCountByDate, func(ds *dailyLivestreamStats, v int64) { ds.RefundCount += v })
dailyList := make([]dailyLivestreamStats, 0, len(dailyMap))
for _, ds := range dailyMap {
ds.NetProfit = (ds.TotalRevenue - ds.TotalRefund) - ds.TotalCost
netRevenue := ds.TotalRevenue - ds.TotalRefund
if netRevenue > 0 {
ds.ProfitMargin = math.Trunc(float64(ds.NetProfit)/float64(netRevenue)*10000) / 100
} else if netRevenue == 0 && ds.TotalCost > 0 {
ds.ProfitMargin = -100
}
dailyList = append(dailyList, *ds)
}
sort.Slice(dailyList, func(i, j int) bool { return dailyList[i].Date > dailyList[j].Date })
netProfit := (orderSummary.TotalRevenue - orderSummary.TotalRefund) - costSummary.TotalCost
netRevenue := orderSummary.TotalRevenue - orderSummary.TotalRefund
margin := 0.0
if netRevenue > 0 {
margin = math.Trunc(float64(netProfit)/float64(netRevenue)*10000) / 100
} else if netRevenue == 0 && costSummary.TotalCost > 0 {
margin = -100
}
return &livestreamMetrics{
UserCount: orderSummary.UserCount,
OrderCount: orderSummary.OrderCount,
RefundCount: orderSummary.RefundCount,
TotalRevenue: orderSummary.TotalRevenue,
TotalRefund: orderSummary.TotalRefund,
TotalCost: costSummary.TotalCost,
NetProfit: netProfit,
ProfitMargin: margin,
Daily: dailyList,
}, nil
}
func (h *handler) countLivestreamUsers(scope *gorm.DB) (int64, error) {
type userRef struct {
DouyinUserID string `gorm:"column:douyin_user_id"`
LocalUserID int64 `gorm:"column:local_user_id"`
}
var refs []userRef
if err := scope.Session(&gorm.Session{}).
Select("douyin_user_id, local_user_id").
Scan(&refs).Error; err != nil {
return 0, err
}
seen := make(map[string]struct{}, len(refs))
for _, ref := range refs {
if id := strings.TrimSpace(ref.DouyinUserID); id != "" {
seen["dy:"+id] = struct{}{}
continue
}
if ref.LocalUserID > 0 {
seen[fmt.Sprintf("local:%d", ref.LocalUserID)] = struct{}{}
}
}
return int64(len(seen)), nil
}
func (h *handler) loadLivestreamOrderSummary(scope *gorm.DB, ticketPrice int64) (*livestreamOrderSummary, error) {
type orderRef struct {
OrderID int64 `gorm:"column:order_id"`
FirstDrawAtRaw string `gorm:"column:first_draw_at"`
}
var orderRefs []orderRef
if err := scope.Session(&gorm.Session{}).
Select("dl.douyin_order_id AS order_id, MIN(dl.created_at) AS first_draw_at").
Where("dl.douyin_order_id > 0").
Group("dl.douyin_order_id").
Scan(&orderRefs).Error; err != nil {
return nil, err
}
result := &livestreamOrderSummary{
RefundedOrderIDs: make(map[int64]bool),
RevenueByDate: make(map[string]int64),
RefundByDate: make(map[string]int64),
OrderCountByDate: make(map[string]int64),
RefundCountByDate: make(map[string]int64),
}
userCount, err := h.countLivestreamUsers(scope)
if err != nil {
return nil, err
}
result.UserCount = userCount
orderIDs := make([]int64, 0, len(orderRefs))
for _, ref := range orderRefs {
if ref.OrderID > 0 {
orderIDs = append(orderIDs, ref.OrderID)
}
}
if len(orderIDs) == 0 {
return result, nil
}
var orders []model.DouyinOrders
if err := h.repo.GetDbR().
Select("id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count").
Where("id IN ?", orderIDs).
Find(&orders).Error; err != nil {
return nil, err
}
orderMap := make(map[int64]*model.DouyinOrders, len(orders))
for i := range orders {
orderMap[orders[i].ID] = &orders[i]
}
for _, ref := range orderRefs {
order := orderMap[ref.OrderID]
if order == nil {
continue
}
amount := calcLivestreamOrderAmount(order, ticketPrice)
if amount < 0 {
amount = 0
}
dateKey := time.Now().In(time.Local).Format("2006-01-02")
if ref.FirstDrawAtRaw != "" {
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05.999", ref.FirstDrawAtRaw, time.Local); err == nil {
dateKey = parsed.In(time.Local).Format("2006-01-02")
} else if parsed, err := time.ParseInLocation("2006-01-02 15:04:05", ref.FirstDrawAtRaw, time.Local); err == nil {
dateKey = parsed.In(time.Local).Format("2006-01-02")
} else if parsed, err := time.ParseInLocation(time.RFC3339Nano, ref.FirstDrawAtRaw, time.Local); err == nil {
dateKey = parsed.In(time.Local).Format("2006-01-02")
}
}
result.OrderCount++
result.TotalRevenue += amount
result.RevenueByDate[dateKey] += amount
result.OrderCountByDate[dateKey]++
if order.OrderStatus == 4 {
result.RefundCount++
result.TotalRefund += amount
result.RefundedOrderIDs[order.ID] = true
result.RefundByDate[dateKey] += amount
result.RefundCountByDate[dateKey]++
}
}
return result, nil
}
func (h *handler) loadLivestreamCostSummary(scope *gorm.DB, refundedOrderIDs map[int64]bool) (*livestreamCostSummary, error) {
type costRow struct {
DouyinOrderID int64 `gorm:"column:douyin_order_id"`
CreatedAt time.Time `gorm:"column:created_at"`
IsRefunded int32 `gorm:"column:is_refunded"`
UnitCost int64 `gorm:"column:unit_cost"`
}
var rows []costRow
if err := scope.Session(&gorm.Session{}).
Table("livestream_draw_logs AS dl").
Select(`
dl.douyin_order_id,
dl.created_at,
dl.is_refunded,
COALESCE(p.cost_price, 0) AS unit_cost
`).
Joins("LEFT JOIN livestream_prizes lp ON lp.id = dl.prize_id").
Joins("LEFT JOIN products p ON p.id = COALESCE(NULLIF(dl.product_id, 0), lp.product_id)").
Scan(&rows).Error; err != nil {
return nil, err
}
result := &livestreamCostSummary{CostByDate: make(map[string]int64)}
for _, row := range rows {
if row.IsRefunded == 1 || refundedOrderIDs[row.DouyinOrderID] {
continue
}
result.TotalCost += row.UnitCost
result.CostByDate[row.CreatedAt.In(time.Local).Format("2006-01-02")] += row.UnitCost
}
return result, nil
}
func parseLivestreamDateRange(startRaw, endRaw string, withTime bool) (*time.Time, *time.Time) {
var startTime, endTime *time.Time
if startRaw != "" {
if withTime {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", startRaw, time.Local); err == nil {
startTime = &t
} else if t, err := time.ParseInLocation("2006-01-02", startRaw, time.Local); err == nil {
startTime = &t
}
} else if t, err := time.ParseInLocation("2006-01-02", startRaw, time.Local); err == nil {
startTime = &t
}
}
if endRaw != "" {
if withTime {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", endRaw, time.Local); err == nil {
endTime = &t
} else if t, err := time.ParseInLocation("2006-01-02", endRaw, time.Local); err == nil {
end := t.Add(24*time.Hour - time.Nanosecond)
endTime = &end
}
} else if t, err := time.ParseInLocation("2006-01-02", endRaw, time.Local); err == nil {
end := t.Add(24*time.Hour - time.Nanosecond)
endTime = &end
}
}
return startTime, endTime
}
func parseExcludeUserIDs(raw string) []int64 {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
ids := make([]int64, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if id, err := strconv.ParseInt(part, 10, 64); err == nil && id > 0 {
ids = append(ids, id)
}
}
return ids
}

View File

@ -0,0 +1,420 @@
package admin
import (
"testing"
"time"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
)
func TestBuildLivestreamMetrics_UsesProductCostAndIgnoresTransfers(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatal(err)
}
ddls := []string{
`CREATE TABLE livestream_activities (id INTEGER PRIMARY KEY, ticket_price INTEGER)`,
`CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`,
`CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`,
`CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`,
`CREATE TABLE livestream_draw_logs (
id INTEGER PRIMARY KEY,
activity_id INTEGER,
prize_id INTEGER,
douyin_order_id INTEGER,
shop_order_id TEXT,
local_user_id INTEGER,
douyin_user_id TEXT,
product_id INTEGER,
prize_name TEXT,
user_nickname TEXT,
created_at DATETIME,
is_refunded INTEGER
)`,
`CREATE TABLE user_inventory (
id INTEGER PRIMARY KEY,
user_id INTEGER,
reward_id INTEGER,
product_id INTEGER,
status INTEGER,
value_cents INTEGER,
remark TEXT,
created_at DATETIME
)`,
}
for _, ddl := range ddls {
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
t.Fatal(err)
}
}
sqls := []string{
`INSERT INTO livestream_activities (id, ticket_price) VALUES (1, 990)`,
`INSERT INTO products (id, cost_price) VALUES (101, 500), (102, 700)`,
`INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999), (12, 1, 102, 8888)`,
`INSERT INTO douyin_orders (id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count) VALUES (201, 'SO-1', 990, 2, '微信支付', 1)`,
`INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES
(301, 1, 11, 201, 'SO-1', 9001, 101, 'P1', 'U1', '2026-04-01 10:00:00', 0),
(302, 1, 12, 201, 'SO-1', 9001, 102, 'P2', 'U1', '2026-04-01 10:01:00', 0)`,
`INSERT INTO user_inventory (id, user_id, reward_id, product_id, status, value_cents, remark, created_at) VALUES
(401, 9999, 0, 101, 3, 999999, 'transferred_from_9001|shipping_requested', '2026-04-01 11:00:00'),
(402, 9999, 0, 102, 3, 999999, 'transferred_from_9001|shipping_requested', '2026-04-01 11:01:00')`,
}
for _, sql := range sqls {
if err := repo.GetDbW().Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
if err != nil {
t.Fatal(err)
}
h := &handler{logger: lg, repo: repo}
start := mustParseDateTime(t, "2026-04-01 00:00:00")
end := mustParseDateTime(t, "2026-04-01 23:59:59")
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990)
if err != nil {
t.Fatal(err)
}
if metrics.TotalCost != 1200 {
t.Fatalf("TotalCost=%d want 1200", metrics.TotalCost)
}
if metrics.TotalRevenue != 990 {
t.Fatalf("TotalRevenue=%d want 990", metrics.TotalRevenue)
}
if metrics.NetProfit != -210 {
t.Fatalf("NetProfit=%d want -210", metrics.NetProfit)
}
if len(metrics.Daily) != 1 || metrics.Daily[0].TotalCost != 1200 {
t.Fatalf("unexpected daily=%+v", metrics.Daily)
}
}
func TestBuildLivestreamMetrics_ExcludesRefundedOrdersFromCost(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatal(err)
}
ddls := []string{
`CREATE TABLE livestream_activities (id INTEGER PRIMARY KEY, ticket_price INTEGER)`,
`CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`,
`CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`,
`CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`,
`CREATE TABLE livestream_draw_logs (
id INTEGER PRIMARY KEY,
activity_id INTEGER,
prize_id INTEGER,
douyin_order_id INTEGER,
shop_order_id TEXT,
local_user_id INTEGER,
douyin_user_id TEXT,
product_id INTEGER,
prize_name TEXT,
user_nickname TEXT,
created_at DATETIME,
is_refunded INTEGER
)`}
for _, ddl := range ddls {
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
t.Fatal(err)
}
}
sqls := []string{
`INSERT INTO livestream_activities (id, ticket_price) VALUES (1, 990)`,
`INSERT INTO products (id, cost_price) VALUES (101, 500), (102, 700)`,
`INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999), (12, 1, 102, 8888)`,
`INSERT INTO douyin_orders (id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count) VALUES
(201, 'SO-1', 990, 2, '微信支付', 1),
(202, 'SO-2', 1990, 4, '微信支付', 1)`,
`INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES
(301, 1, 11, 201, 'SO-1', 9001, 101, 'P1', 'U1', '2026-04-01 10:00:00', 0),
(302, 1, 12, 202, 'SO-2', 9002, 102, 'P2', 'U2', '2026-04-01 10:01:00', 1)`,
}
for _, sql := range sqls {
if err := repo.GetDbW().Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
if err != nil {
t.Fatal(err)
}
h := &handler{logger: lg, repo: repo}
start := mustParseDateTime(t, "2026-04-01 00:00:00")
end := mustParseDateTime(t, "2026-04-01 23:59:59")
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990)
if err != nil {
t.Fatal(err)
}
if metrics.TotalRevenue != 2980 {
t.Fatalf("TotalRevenue=%d want 2980", metrics.TotalRevenue)
}
if metrics.TotalRefund != 1990 {
t.Fatalf("TotalRefund=%d want 1990", metrics.TotalRefund)
}
if metrics.TotalCost != 500 {
t.Fatalf("TotalCost=%d want 500", metrics.TotalCost)
}
}
func TestBuildLivestreamMetrics_DoesNotCountZeroOrderIDAsOrder(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatal(err)
}
ddls := []string{
`CREATE TABLE livestream_activities (id INTEGER PRIMARY KEY, ticket_price INTEGER)`,
`CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`,
`CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`,
`CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`,
`CREATE TABLE livestream_draw_logs (
id INTEGER PRIMARY KEY,
activity_id INTEGER,
prize_id INTEGER,
douyin_order_id INTEGER,
shop_order_id TEXT,
local_user_id INTEGER,
douyin_user_id TEXT,
product_id INTEGER,
prize_name TEXT,
user_nickname TEXT,
created_at DATETIME,
is_refunded INTEGER
)`}
for _, ddl := range ddls {
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
t.Fatal(err)
}
}
sqls := []string{
`INSERT INTO livestream_activities (id, ticket_price) VALUES (1, 990)`,
`INSERT INTO products (id, cost_price) VALUES (101, 500)`,
`INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999)`,
`INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES
(301, 1, 11, 0, '', 9001, 101, 'P1', 'U1', '2026-04-01 10:00:00', 0),
(302, 1, 11, 0, '', 9001, 101, 'P1', 'U1', '2026-04-01 10:01:00', 0)`}
for _, sql := range sqls {
if err := repo.GetDbW().Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
if err != nil {
t.Fatal(err)
}
h := &handler{logger: lg, repo: repo}
start := mustParseDateTime(t, "2026-04-01 00:00:00")
end := mustParseDateTime(t, "2026-04-01 23:59:59")
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990)
if err != nil {
t.Fatal(err)
}
if metrics.OrderCount != 0 {
t.Fatalf("OrderCount=%d want 0", metrics.OrderCount)
}
if metrics.TotalRevenue != 0 {
t.Fatalf("TotalRevenue=%d want 0", metrics.TotalRevenue)
}
if metrics.TotalCost != 1000 {
t.Fatalf("TotalCost=%d want 1000", metrics.TotalCost)
}
}
func TestBuildLivestreamMetrics_FallbackToPrizeProductIDWhenDrawLogProductMissing(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatal(err)
}
ddls := []string{
`CREATE TABLE livestream_activities (id INTEGER PRIMARY KEY, ticket_price INTEGER)`,
`CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`,
`CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`,
`CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`,
`CREATE TABLE livestream_draw_logs (
id INTEGER PRIMARY KEY,
activity_id INTEGER,
prize_id INTEGER,
douyin_order_id INTEGER,
shop_order_id TEXT,
local_user_id INTEGER,
douyin_user_id TEXT,
product_id INTEGER,
prize_name TEXT,
user_nickname TEXT,
created_at DATETIME,
is_refunded INTEGER
)`}
for _, ddl := range ddls {
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
t.Fatal(err)
}
}
sqls := []string{
`INSERT INTO livestream_activities (id, ticket_price) VALUES (1, 990)`,
`INSERT INTO products (id, cost_price) VALUES (101, 500)`,
`INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999)`,
`INSERT INTO douyin_orders (id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count) VALUES (201, 'SO-1', 990, 2, '微信支付', 1)`,
`INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES
(301, 1, 11, 201, 'SO-1', 9001, 0, 'P1', 'U1', '2026-04-01 10:00:00', 0)`}
for _, sql := range sqls {
if err := repo.GetDbW().Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
if err != nil {
t.Fatal(err)
}
h := &handler{logger: lg, repo: repo}
start := mustParseDateTime(t, "2026-04-01 00:00:00")
end := mustParseDateTime(t, "2026-04-01 23:59:59")
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990)
if err != nil {
t.Fatal(err)
}
if metrics.TotalCost != 500 {
t.Fatalf("TotalCost=%d want 500", metrics.TotalCost)
}
}
func TestBuildLivestreamMetrics_CountsDouyinUsersWhenLocalUserMissing(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatal(err)
}
ddls := []string{
`CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`,
`CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`,
`CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`,
`CREATE TABLE livestream_draw_logs (
id INTEGER PRIMARY KEY,
activity_id INTEGER,
prize_id INTEGER,
douyin_order_id INTEGER,
shop_order_id TEXT,
local_user_id INTEGER,
douyin_user_id TEXT,
product_id INTEGER,
prize_name TEXT,
user_nickname TEXT,
created_at DATETIME,
is_refunded INTEGER
)`}
for _, ddl := range ddls {
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
t.Fatal(err)
}
}
sqls := []string{
`INSERT INTO products (id, cost_price) VALUES (101, 500)`,
`INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999)`,
`INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, douyin_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES
(301, 1, 11, 0, '', 0, 'dy-1', 101, 'P1', 'U1', '2026-04-01 10:00:00', 0),
(302, 1, 11, 0, '', 0, 'dy-2', 101, 'P1', 'U2', '2026-04-01 10:01:00', 0)`}
for _, sql := range sqls {
if err := repo.GetDbW().Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
if err != nil {
t.Fatal(err)
}
h := &handler{logger: lg, repo: repo}
start := mustParseDateTime(t, "2026-04-01 00:00:00")
end := mustParseDateTime(t, "2026-04-01 23:59:59")
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990)
if err != nil {
t.Fatal(err)
}
if metrics.UserCount != 2 {
t.Fatalf("UserCount=%d want 2", metrics.UserCount)
}
}
func TestBuildLivestreamMetrics_ExcludesRefundedLogsWhenOrderRowMissing(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatal(err)
}
ddls := []string{
`CREATE TABLE douyin_orders (id INTEGER PRIMARY KEY, shop_order_id TEXT, actual_pay_amount INTEGER, order_status INTEGER, pay_type_desc TEXT, product_count INTEGER)`,
`CREATE TABLE livestream_prizes (id INTEGER PRIMARY KEY, activity_id INTEGER, product_id INTEGER, cost_price INTEGER)`,
`CREATE TABLE products (id INTEGER PRIMARY KEY, cost_price INTEGER)`,
`CREATE TABLE livestream_draw_logs (
id INTEGER PRIMARY KEY,
activity_id INTEGER,
prize_id INTEGER,
douyin_order_id INTEGER,
shop_order_id TEXT,
local_user_id INTEGER,
douyin_user_id TEXT,
product_id INTEGER,
prize_name TEXT,
user_nickname TEXT,
created_at DATETIME,
is_refunded INTEGER
)`}
for _, ddl := range ddls {
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
t.Fatal(err)
}
}
sqls := []string{
`INSERT INTO products (id, cost_price) VALUES (101, 500)`,
`INSERT INTO livestream_prizes (id, activity_id, product_id, cost_price) VALUES (11, 1, 101, 9999)`,
`INSERT INTO livestream_draw_logs (id, activity_id, prize_id, douyin_order_id, shop_order_id, local_user_id, product_id, prize_name, user_nickname, created_at, is_refunded) VALUES
(301, 1, 11, 201, 'SO-1', 9001, 101, 'P1', 'U1', '2026-04-01 10:00:00', 1)`}
for _, sql := range sqls {
if err := repo.GetDbW().Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
if err != nil {
t.Fatal(err)
}
h := &handler{logger: lg, repo: repo}
start := mustParseDateTime(t, "2026-04-01 00:00:00")
end := mustParseDateTime(t, "2026-04-01 23:59:59")
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{ActivityID: 1, StartTime: &start, EndTime: &end}, 990)
if err != nil {
t.Fatal(err)
}
if metrics.TotalCost != 0 {
t.Fatalf("TotalCost=%d want 0", metrics.TotalCost)
}
}
func mustParseDateTime(t *testing.T, value string) time.Time {
t.Helper()
ts, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
if err != nil {
t.Fatalf("parse %q: %v", value, err)
}
return ts
}

View File

@ -1,11 +1,8 @@
package admin
import (
"math"
"net/http"
"strconv"
"strings"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
@ -58,295 +55,33 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
EndTime string `form:"end_time"`
})
_ = ctx.ShouldBindQuery(req)
startTime, endTime := parseLivestreamDateRange(req.StartTime, req.EndTime, false)
var startTime, endTime *time.Time
if req.StartTime != "" {
if t, err := time.ParseInLocation("2006-01-02", req.StartTime, time.Local); err == nil {
startTime = &t
}
}
if req.EndTime != "" {
if t, err := time.ParseInLocation("2006-01-02", req.EndTime, time.Local); err == nil {
end := t.Add(24*time.Hour - time.Nanosecond)
endTime = &end
}
}
// 1. 获取活动信息(门票价格)
var activity model.LivestreamActivities
if err := h.repo.GetDbR().Where("id = ?", id).First(&activity).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
return
}
ticketPrice := int64(activity.TicketPrice)
// 2. 统计营收/退款基于订单去重并兼容次卡0元订单按门票价计入
type orderRef struct {
OrderID int64
FirstDrawAt time.Time
}
orderQuery := h.repo.GetDbR().Table(model.TableNameLivestreamDrawLogs).
Select("douyin_order_id AS order_id, MIN(created_at) AS first_draw_at").
Where("activity_id = ?", id).
Where("douyin_order_id > 0")
if startTime != nil {
orderQuery = orderQuery.Where("created_at >= ?", startTime)
}
if endTime != nil {
orderQuery = orderQuery.Where("created_at <= ?", endTime)
}
var orderRefs []orderRef
if err := orderQuery.Group("douyin_order_id").Scan(&orderRefs).Error; err != nil {
metrics, err := h.buildLivestreamMetrics(livestreamMetricsFilter{
ActivityID: id,
StartTime: startTime,
EndTime: endTime,
}, int64(activity.TicketPrice))
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
orderIDs := make([]int64, 0, len(orderRefs))
for _, ref := range orderRefs {
if ref.OrderID == 0 {
continue
}
orderIDs = append(orderIDs, ref.OrderID)
}
orderMap := make(map[int64]*model.DouyinOrders, len(orderIDs))
if len(orderIDs) > 0 {
var orders []model.DouyinOrders
if err := h.repo.GetDbR().
Select("id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count").
Where("id IN ?", orderIDs).
Find(&orders).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
for i := range orders {
orderMap[orders[i].ID] = &orders[i]
}
}
dailyMap := make(map[string]*dailyLivestreamStats)
refundedShopOrderIDs := make(map[string]bool)
var totalRevenue, totalRefund int64
var orderCount, refundCount int64
for _, ref := range orderRefs {
order := orderMap[ref.OrderID]
if order == nil {
continue
}
amount := calcLivestreamOrderAmount(order, ticketPrice)
if amount < 0 {
amount = 0
}
dateKey := ref.FirstDrawAt.In(time.Local).Format("2006-01-02")
if ref.FirstDrawAt.IsZero() {
dateKey = time.Now().In(time.Local).Format("2006-01-02")
}
refunded := order.OrderStatus == 4
orderCount++
totalRevenue += amount
if refunded {
totalRefund += amount
refundCount++
}
if refunded && order.ShopOrderID != "" {
refundedShopOrderIDs[order.ShopOrderID] = true
}
ds := dailyMap[dateKey]
if ds == nil {
ds = &dailyLivestreamStats{Date: dateKey}
dailyMap[dateKey] = ds
}
ds.TotalRevenue += amount
ds.OrderCount++
if refunded {
ds.TotalRefund += amount
ds.RefundCount++
}
}
// 3. 获取所有抽奖记录用于成本计算
var drawLogs []model.LivestreamDrawLogs
db := h.repo.GetDbR().Where("activity_id = ?", id)
if startTime != nil {
db = db.Where("created_at >= ?", startTime)
}
if endTime != nil {
db = db.Where("created_at <= ?", endTime)
}
db.Find(&drawLogs)
// 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本
// 4. 计算成本(优先资产快照 user_inventory.value_cents缺失回退 livestream_prizes.cost_price
prizeCostMap := make(map[int64]int64)
prizeIDs := make([]int64, 0)
prizeIDSet := make(map[int64]struct{})
userIDSet := make(map[int64]struct{})
for _, log := range drawLogs {
if log.PrizeID > 0 {
if _, ok := prizeIDSet[log.PrizeID]; !ok {
prizeIDSet[log.PrizeID] = struct{}{}
prizeIDs = append(prizeIDs, log.PrizeID)
}
}
if log.LocalUserID > 0 {
userIDSet[log.LocalUserID] = struct{}{}
}
}
if len(prizeIDs) > 0 {
var prizes []model.LivestreamPrizes
h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes)
for _, p := range prizes {
prizeCostMap[p.ID] = p.CostPrice
}
}
type inventorySnapshot struct {
UserID int64
ValueCents int64
Remark string
CreatedAt time.Time
}
invByUser := make(map[int64][]inventorySnapshot)
if len(userIDSet) > 0 {
userIDs := make([]int64, 0, len(userIDSet))
for uid := range userIDSet {
userIDs = append(userIDs, uid)
}
var inventories []inventorySnapshot
invDB := h.repo.GetDbR().Table("user_inventory").
Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark, user_inventory.created_at").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
Where("user_id IN ?", userIDs).
Where("status IN (1, 3)").
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
if startTime != nil {
invDB = invDB.Where("created_at >= ?", startTime.Add(-2*time.Hour))
}
if endTime != nil {
invDB = invDB.Where("created_at <= ?", endTime.Add(24*time.Hour))
}
_ = invDB.Scan(&inventories).Error
for _, inv := range inventories {
invByUser[inv.UserID] = append(invByUser[inv.UserID], inv)
}
}
type logRef struct {
PrizeID int64
DateKey string
}
logsByKey := make(map[string][]logRef)
keyUser := make(map[string]int64)
keyOrder := make(map[string]string)
for _, log := range drawLogs {
if refundedShopOrderIDs[log.ShopOrderID] {
continue
}
key := strconv.FormatInt(log.LocalUserID, 10) + "|" + log.ShopOrderID
logsByKey[key] = append(logsByKey[key], logRef{
PrizeID: log.PrizeID,
DateKey: log.CreatedAt.Format("2006-01-02"),
})
keyUser[key] = log.LocalUserID
keyOrder[key] = log.ShopOrderID
}
costByDate := make(map[string]int64)
var totalCost int64
for key, refs := range logsByKey {
if len(refs) == 0 {
continue
}
uid := keyUser[key]
shopOrderID := keyOrder[key]
var snapshotSum int64
if uid > 0 && shopOrderID != "" {
for _, inv := range invByUser[uid] {
if strings.Contains(inv.Remark, shopOrderID) {
snapshotSum += inv.ValueCents
}
}
}
if snapshotSum > 0 {
avg := snapshotSum / int64(len(refs))
rem := snapshotSum - avg*int64(len(refs))
for i, r := range refs {
c := avg
if i == 0 {
c += rem
}
totalCost += c
costByDate[r.DateKey] += c
}
continue
}
for _, r := range refs {
c := prizeCostMap[r.PrizeID]
totalCost += c
costByDate[r.DateKey] += c
}
}
// 5. 按天分组统计 (营收/退款已在 dailyMap 中累计),补充成本
for dateKey, c := range costByDate {
ds := dailyMap[dateKey]
if ds == nil {
ds = &dailyLivestreamStats{Date: dateKey}
dailyMap[dateKey] = ds
}
ds.TotalCost += c
}
// 6. 汇总每日数据并计算总体指标
var calcTotalRevenue, calcTotalRefund, calcTotalCost int64
dailyList := make([]dailyLivestreamStats, 0, len(dailyMap))
for _, ds := range dailyMap {
ds.NetProfit = (ds.TotalRevenue - ds.TotalRefund) - ds.TotalCost
netRev := ds.TotalRevenue - ds.TotalRefund
if netRev > 0 {
ds.ProfitMargin = math.Trunc(float64(ds.NetProfit)/float64(netRev)*10000) / 100
} else if netRev == 0 && ds.TotalCost > 0 {
ds.ProfitMargin = -100
}
dailyList = append(dailyList, *ds)
calcTotalRevenue += ds.TotalRevenue
calcTotalRefund += ds.TotalRefund
calcTotalCost += ds.TotalCost
}
netProfit := (totalRevenue - totalRefund) - totalCost
var margin float64
netRevenue := totalRevenue - totalRefund
if netRevenue > 0 {
margin = float64(netProfit) / float64(netRevenue) * 100
} else if netRevenue == 0 && totalCost > 0 {
margin = -100
} else {
margin = 0
}
ctx.Payload(&livestreamStatsResponse{
TotalRevenue: totalRevenue,
TotalRefund: totalRefund,
TotalCost: totalCost,
NetProfit: netProfit,
OrderCount: orderCount,
RefundCount: refundCount,
ProfitMargin: math.Trunc(margin*100) / 100,
Daily: dailyList,
TotalRevenue: metrics.TotalRevenue,
TotalRefund: metrics.TotalRefund,
TotalCost: metrics.TotalCost,
NetProfit: metrics.NetProfit,
OrderCount: metrics.OrderCount,
RefundCount: metrics.RefundCount,
ProfitMargin: metrics.ProfitMargin,
Daily: metrics.Daily,
})
}
}