fix: treat livestream pass orders as ticket price
This commit is contained in:
parent
8b7af03400
commit
9972427cea
@ -5,6 +5,7 @@ import (
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
financesvc "bindbox-game/internal/service/finance"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@ -35,6 +36,11 @@ type activityProfitLossItem struct {
|
||||
TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分)
|
||||
TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分)
|
||||
TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分)
|
||||
SpendingPaidCoupon int64 `json:"spending_paid_coupon"`
|
||||
SpendingGamePass int64 `json:"spending_game_pass"`
|
||||
PrizeCostBase int64 `json:"prize_cost_base"`
|
||||
PrizeCostMultiplier int64 `json:"prize_cost_multiplier"`
|
||||
PrizeCostFinal int64 `json:"prize_cost_final"`
|
||||
Profit int64 `json:"profit"` // (Revenue + Discount + GamePassValue) - Cost
|
||||
ProfitRate float64 `json:"profit_rate"` // Profit / (Revenue + Discount + GamePassValue)
|
||||
}
|
||||
@ -170,14 +176,19 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
|
||||
}
|
||||
var revenueStats []revenueStat
|
||||
|
||||
// 修正: 按抽奖次数比例分摊订单金额 (解决多活动订单归因问题)
|
||||
// 逻辑: 活动分摊收入 = 订单实际金额 * (该活动在该订单中的抽奖次数 / 该订单总抽奖次数)
|
||||
// 修正: 按抽奖次数比例分摊订单金额,且次卡订单不计入支付+优惠券口径(严格二选一)
|
||||
var err error
|
||||
err = db.Table(model.TableNameOrders).
|
||||
Select(`
|
||||
order_activity_draws.activity_id,
|
||||
SUM(1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count) as total_revenue,
|
||||
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' THEN 0 ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count END) as total_discount
|
||||
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
|
||||
THEN 0
|
||||
ELSE 1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count
|
||||
END) as total_revenue,
|
||||
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
|
||||
THEN 0
|
||||
ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count
|
||||
END) as total_discount
|
||||
`).
|
||||
// Subquery 1: Calculate draw counts per order per activity (and link to issue->activity)
|
||||
Joins(`JOIN (
|
||||
@ -213,19 +224,39 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
|
||||
type costStat struct {
|
||||
ActivityID int64
|
||||
TotalCost int64
|
||||
TotalCostBase int64
|
||||
AvgMultiplierX10 int64
|
||||
}
|
||||
var costStats []costStat
|
||||
db.Table(model.TableNameUserInventory).
|
||||
Select("user_inventory.activity_id, SUM(user_inventory.value_cents) as total_cost").
|
||||
if err := db.Table(model.TableNameUserInventory).
|
||||
Select(`
|
||||
COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) as activity_id,
|
||||
CAST(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000) AS SIGNED) as total_cost,
|
||||
SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0)) as total_cost_base,
|
||||
CAST(COALESCE(AVG(GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 100), 10) AS SIGNED) as avg_multiplier_x10
|
||||
`).
|
||||
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
|
||||
Where("user_inventory.activity_id IN ?", activityIDs).
|
||||
Where("orders.status = ?", 2). // 仅统计已支付订单产生的成本
|
||||
Group("user_inventory.activity_id").
|
||||
Scan(&costStats)
|
||||
|
||||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||
Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.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("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) IN ?", activityIDs).
|
||||
Where("user_inventory.status IN ?", []int{1, 3}).
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
// 兼容历史数据:部分老资产可能未写入 order_id,避免被 JOIN 条件整批过滤为0
|
||||
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2).
|
||||
Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id)").
|
||||
Scan(&costStats).Error; err != nil {
|
||||
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss cost stats error: %v", err))
|
||||
} else {
|
||||
for _, s := range costStats {
|
||||
if item, ok := activityMap[s.ActivityID]; ok {
|
||||
item.TotalCost = s.TotalCost
|
||||
item.PrizeCostBase = s.TotalCostBase
|
||||
item.PrizeCostFinal = s.TotalCost
|
||||
item.PrizeCostMultiplier = s.AvgMultiplierX10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -263,15 +294,14 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
|
||||
}
|
||||
|
||||
// 6. 计算盈亏和比率
|
||||
// 公式: 盈亏 = (支付金额 + 优惠券抵扣 + 次卡价值) - 产品成本
|
||||
// 公式: 盈亏 = 用户支出(普通单支付+优惠券 或 次卡价值) - 奖品成本(含道具卡倍率)
|
||||
finalList := make([]activityProfitLossItem, 0, len(activities))
|
||||
for _, a := range activities {
|
||||
item := activityMap[a.ID]
|
||||
totalIncome := item.TotalRevenue + item.TotalDiscount + item.TotalGamePassValue
|
||||
item.Profit = totalIncome - item.TotalCost
|
||||
if totalIncome > 0 {
|
||||
item.ProfitRate = float64(item.Profit) / float64(totalIncome)
|
||||
}
|
||||
item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount
|
||||
item.SpendingGamePass = item.TotalGamePassValue
|
||||
totalIncome := item.SpendingPaidCoupon + item.SpendingGamePass
|
||||
item.Profit, item.ProfitRate = financesvc.ComputeProfit(totalIncome, item.TotalCost)
|
||||
finalList = append(finalList, *item)
|
||||
}
|
||||
|
||||
@ -417,6 +447,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
||||
DrawCount int64 // 该订单的总抽奖次数(用于金额分摊)
|
||||
UsedDrawLogID int64 // 道具卡实际使用的日志ID
|
||||
CreatedAt time.Time
|
||||
ActivityPrice int64
|
||||
}
|
||||
|
||||
logsQuery := db.Table(model.TableNameActivityDrawLogs).
|
||||
@ -444,9 +475,11 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
||||
COALESCE(orders.order_no, '') as order_no,
|
||||
COALESCE(order_draw_counts.draw_count, 1) as draw_count,
|
||||
COALESCE(user_item_cards.used_draw_log_id, 0) as used_draw_log_id,
|
||||
activity_draw_logs.created_at
|
||||
activity_draw_logs.created_at,
|
||||
COALESCE(activities.price_draw, 0) as activity_price
|
||||
`).
|
||||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||||
Joins("JOIN activities ON activities.id = activity_issues.activity_id").
|
||||
Joins("LEFT JOIN users ON users.id = activity_draw_logs.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").
|
||||
@ -589,6 +622,14 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
||||
perDrawDiscountAmount := l.DiscountAmount / drawCount
|
||||
perDrawPointsAmount := l.PointsAmount / drawCount
|
||||
|
||||
if paymentDetails.GamePassUsed {
|
||||
if l.ActivityPrice > 0 {
|
||||
perDrawOrderAmount = l.ActivityPrice
|
||||
} else if perDrawOrderAmount == 0 {
|
||||
perDrawOrderAmount = l.OrderAmount / drawCount
|
||||
}
|
||||
}
|
||||
|
||||
// 设置支付详情中的分摊金额
|
||||
paymentDetails.CouponDiscount = perDrawDiscountAmount
|
||||
paymentDetails.PointsDiscount = perDrawPointsAmount
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -10,6 +13,7 @@ import (
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"bindbox-game/internal/service/channel"
|
||||
"bindbox-game/internal/service/livestream"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@ -21,6 +25,7 @@ type createLivestreamActivityRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
StreamerName string `json:"streamer_name"`
|
||||
StreamerContact string `json:"streamer_contact"`
|
||||
ChannelID *int64 `json:"channel_id"`
|
||||
DouyinProductID string `json:"douyin_product_id"`
|
||||
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型: flip_card/minesweeper
|
||||
OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量: 1-100
|
||||
@ -34,6 +39,9 @@ type livestreamActivityResponse struct {
|
||||
Name string `json:"name"`
|
||||
StreamerName string `json:"streamer_name"`
|
||||
StreamerContact string `json:"streamer_contact"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelCode string `json:"channel_code"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
AccessCode string `json:"access_code"`
|
||||
DouyinProductID string `json:"douyin_product_id"`
|
||||
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
|
||||
@ -64,10 +72,35 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
var channelCode string
|
||||
var channelName string
|
||||
if req.ChannelID != nil && *req.ChannelID > 0 {
|
||||
if ch, err := h.channel.GetByID(ctx.RequestContext(), *req.ChannelID); err == nil {
|
||||
channelCode = ch.Code
|
||||
channelName = ch.Name
|
||||
if req.StreamerName == "" {
|
||||
req.StreamerName = ch.Name
|
||||
}
|
||||
} else if err == channel.ErrChannelNotFound {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "渠道不存在"))
|
||||
return
|
||||
} else {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
input := livestream.CreateActivityInput{
|
||||
Name: req.Name,
|
||||
StreamerName: req.StreamerName,
|
||||
StreamerContact: req.StreamerContact,
|
||||
ChannelID: func() int64 {
|
||||
if req.ChannelID != nil {
|
||||
return *req.ChannelID
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
ChannelCode: channelCode,
|
||||
DouyinProductID: req.DouyinProductID,
|
||||
OrderRewardType: req.OrderRewardType,
|
||||
OrderRewardQuantity: req.OrderRewardQuantity,
|
||||
@ -91,11 +124,19 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
displayChannelName := channelName
|
||||
if displayChannelName == "" && activity.ChannelCode != "" {
|
||||
displayChannelName = activity.ChannelCode
|
||||
}
|
||||
|
||||
ctx.Payload(&livestreamActivityResponse{
|
||||
ID: activity.ID,
|
||||
Name: activity.Name,
|
||||
StreamerName: activity.StreamerName,
|
||||
StreamerContact: activity.StreamerContact,
|
||||
ChannelID: activity.ChannelID,
|
||||
ChannelCode: activity.ChannelCode,
|
||||
ChannelName: displayChannelName,
|
||||
AccessCode: activity.AccessCode,
|
||||
DouyinProductID: activity.DouyinProductID,
|
||||
OrderRewardType: activity.OrderRewardType,
|
||||
@ -111,6 +152,7 @@ type updateLivestreamActivityRequest struct {
|
||||
Name string `json:"name"`
|
||||
StreamerName string `json:"streamer_name"`
|
||||
StreamerContact string `json:"streamer_contact"`
|
||||
ChannelID *int64 `json:"channel_id"`
|
||||
DouyinProductID string `json:"douyin_product_id"`
|
||||
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
|
||||
OrderRewardQuantity *int32 `json:"order_reward_quantity"` // 下单奖励数量
|
||||
@ -146,6 +188,29 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
var channelCodeValue string
|
||||
var channelCodePtr *string
|
||||
if req.ChannelID != nil {
|
||||
if *req.ChannelID > 0 {
|
||||
if ch, err := h.channel.GetByID(ctx.RequestContext(), *req.ChannelID); err == nil {
|
||||
channelCodeValue = ch.Code
|
||||
channelCodePtr = &channelCodeValue
|
||||
if req.StreamerName == "" {
|
||||
req.StreamerName = ch.Name
|
||||
}
|
||||
} else if err == channel.ErrChannelNotFound {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "渠道不存在"))
|
||||
return
|
||||
} else {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
channelCodeValue = ""
|
||||
channelCodePtr = &channelCodeValue
|
||||
}
|
||||
}
|
||||
|
||||
input := livestream.UpdateActivityInput{
|
||||
Name: req.Name,
|
||||
StreamerName: req.StreamerName,
|
||||
@ -155,6 +220,8 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
|
||||
OrderRewardQuantity: req.OrderRewardQuantity,
|
||||
TicketPrice: req.TicketPrice,
|
||||
Status: req.Status,
|
||||
ChannelID: req.ChannelID,
|
||||
ChannelCode: channelCodePtr,
|
||||
}
|
||||
|
||||
if req.StartTime != "" {
|
||||
@ -224,6 +291,14 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
channelIDs := make([]int64, 0, len(list))
|
||||
for _, a := range list {
|
||||
if a.ChannelID > 0 {
|
||||
channelIDs = append(channelIDs, a.ChannelID)
|
||||
}
|
||||
}
|
||||
channelNameMap := h.loadChannelNames(ctx.RequestContext(), channelIDs)
|
||||
|
||||
res := &listLivestreamActivitiesResponse{
|
||||
List: make([]livestreamActivityResponse, len(list)),
|
||||
Total: total,
|
||||
@ -245,6 +320,13 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc {
|
||||
Status: a.Status,
|
||||
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
item.ChannelID = a.ChannelID
|
||||
item.ChannelCode = a.ChannelCode
|
||||
if name := channelNameMap[a.ChannelID]; name != "" {
|
||||
item.ChannelName = name
|
||||
} else if a.ChannelCode != "" {
|
||||
item.ChannelName = a.ChannelCode
|
||||
}
|
||||
if !a.StartTime.IsZero() {
|
||||
item.StartTime = a.StartTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
@ -283,11 +365,24 @@ func (h *handler) GetLivestreamActivity() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
channelName := ""
|
||||
if activity.ChannelID > 0 {
|
||||
if names := h.loadChannelNames(ctx.RequestContext(), []int64{activity.ChannelID}); len(names) > 0 {
|
||||
channelName = names[activity.ChannelID]
|
||||
}
|
||||
}
|
||||
if channelName == "" && activity.ChannelCode != "" {
|
||||
channelName = activity.ChannelCode
|
||||
}
|
||||
|
||||
res := &livestreamActivityResponse{
|
||||
ID: activity.ID,
|
||||
Name: activity.Name,
|
||||
StreamerName: activity.StreamerName,
|
||||
StreamerContact: activity.StreamerContact,
|
||||
ChannelID: activity.ChannelID,
|
||||
ChannelCode: activity.ChannelCode,
|
||||
ChannelName: channelName,
|
||||
AccessCode: activity.AccessCode,
|
||||
DouyinProductID: activity.DouyinProductID,
|
||||
OrderRewardType: activity.OrderRewardType,
|
||||
@ -335,6 +430,41 @@ func (h *handler) DeleteLivestreamActivity() core.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) loadChannelNames(ctx context.Context, ids []int64) map[int64]string {
|
||||
result := make(map[int64]string)
|
||||
if len(ids) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
unique := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
channels, err := h.readDB.Channels.WithContext(ctx).
|
||||
Select(h.readDB.Channels.ID, h.readDB.Channels.Name).
|
||||
Where(h.readDB.Channels.ID.In(unique...)).
|
||||
Find()
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
for _, ch := range channels {
|
||||
result[ch.ID] = ch.Name
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ========== 直播间奖品管理 ==========
|
||||
|
||||
type createLivestreamPrizeRequest struct {
|
||||
@ -630,6 +760,17 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
var activity model.LivestreamActivities
|
||||
if err := h.repo.GetDbR().Select("id, ticket_price").Where("id = ?", activityID).First(&activity).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ParamBindError, "活动不存在"))
|
||||
} else {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
ticketPrice := int64(activity.TicketPrice)
|
||||
|
||||
req := new(listLivestreamDrawLogsRequest)
|
||||
_ = ctx.ShouldBindForm(req)
|
||||
|
||||
@ -710,10 +851,11 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
||||
DouyinOrderID int64
|
||||
PrizeID int64
|
||||
ShopOrderID string // 用于关联退款状态查 douyin_orders
|
||||
LocalUserID int64
|
||||
}
|
||||
var metas []logMeta
|
||||
// 使用不带分页的 db 克隆
|
||||
if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id").Scan(&metas).Error; err == nil {
|
||||
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)
|
||||
@ -730,61 +872,97 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
||||
if len(orderIDs) > 0 {
|
||||
var orders []model.DouyinOrders
|
||||
// 分批查询防止 IN 子句过长? 暂时假设量级可控
|
||||
h.repo.GetDbR().Select("id, actual_pay_amount, order_status").
|
||||
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 {
|
||||
// 统计营收 (总流水)
|
||||
stats.TotalRev += int64(o.ActualPayAmount)
|
||||
orderAmount := calcLivestreamOrderAmount(&o, ticketPrice)
|
||||
stats.TotalRev += orderAmount
|
||||
|
||||
if o.OrderStatus == 4 { // 已退款
|
||||
stats.TotalRefund += int64(o.ActualPayAmount)
|
||||
stats.TotalRefund += orderAmount
|
||||
orderRefundMap[o.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 统计成本 (剔除退款订单)
|
||||
// 4. 统计成本 (剔除退款订单,优先资产快照,缺失回退 prize.cost_price)
|
||||
for _, m := range metas {
|
||||
if !orderRefundMap[m.DouyinOrderID] {
|
||||
prizeIDCount[m.PrizeID]++
|
||||
}
|
||||
}
|
||||
|
||||
// 计算奖品成本 (逻辑参考 GetLivestreamStats,简化版)
|
||||
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().Where("id IN ?", prizeIDs).Find(&prizes)
|
||||
|
||||
// 批量获取关联商品
|
||||
productIDs := make([]int64, 0)
|
||||
h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes)
|
||||
for _, p := range prizes {
|
||||
if p.CostPrice == 0 && p.ProductID > 0 {
|
||||
productIDs = append(productIDs, p.ProductID)
|
||||
}
|
||||
}
|
||||
productPriceMap := make(map[int64]int64)
|
||||
if len(productIDs) > 0 {
|
||||
var products []model.Products
|
||||
h.repo.GetDbR().Select("id, price").Where("id IN ?", productIDs).Find(&products)
|
||||
for _, prod := range products {
|
||||
productPriceMap[prod.ID] = prod.Price
|
||||
prizeCostMap[p.ID] = p.CostPrice
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range prizes {
|
||||
cost := p.CostPrice
|
||||
if cost == 0 && p.ProductID > 0 {
|
||||
cost = productPriceMap[p.ProductID]
|
||||
// 预加载用户资产快照用于 shop_order_id 命中
|
||||
type invRow struct {
|
||||
UserID int64
|
||||
ValueCents int64
|
||||
Remark string
|
||||
}
|
||||
count := prizeIDCount[p.ID]
|
||||
stats.TotalCost += cost * count
|
||||
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("status IN (1,3)").
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
internal/api/admin/livestream_helpers.go
Normal file
36
internal/api/admin/livestream_helpers.go
Normal file
@ -0,0 +1,36 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
)
|
||||
|
||||
// calcLivestreamOrderAmount returns the effective revenue contribution for a Douyin order.
|
||||
// For regular paid orders it returns actual_pay_amount; for 次卡订单 (actual pay is 0 but
|
||||
// pay_type_desc contains 次卡), it falls back to the activity ticket price.
|
||||
func calcLivestreamOrderAmount(order *model.DouyinOrders, ticketPrice int64) int64 {
|
||||
if order == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
amount := int64(order.ActualPayAmount)
|
||||
if amount > 0 || ticketPrice <= 0 {
|
||||
return amount
|
||||
}
|
||||
|
||||
desc := strings.ReplaceAll(strings.TrimSpace(order.PayTypeDesc), " ", "")
|
||||
if desc == "" {
|
||||
return amount
|
||||
}
|
||||
|
||||
if strings.Contains(desc, "次卡") {
|
||||
multiplier := int64(order.ProductCount)
|
||||
if multiplier <= 0 {
|
||||
multiplier = 1
|
||||
}
|
||||
return ticketPrice * multiplier
|
||||
}
|
||||
|
||||
return amount
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
@ -77,54 +78,98 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
|
||||
return
|
||||
}
|
||||
// ticketPrice 暂未使用,但在统计中可能作为参考,这里移除未使用的报错
|
||||
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")
|
||||
|
||||
// 2. 核心统计逻辑重构:从关联的订单表获取真实金额
|
||||
// 使用子查询或 Join 来获取不重复的订单金额,确保即便一次订单对应多次抽奖,金额也不重计
|
||||
var totalRevenue, orderCount int64
|
||||
// 统计营收:来自已参与过抽奖(产生过日志)且未退款的订单 (order_status != 4)
|
||||
// 使用 actual_pay_amount (实付金额)
|
||||
queryRevenue := `
|
||||
SELECT
|
||||
CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as rev,
|
||||
COUNT(*) as cnt
|
||||
FROM (
|
||||
SELECT DISTINCT o.id, o.actual_pay_amount
|
||||
FROM douyin_orders o
|
||||
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
|
||||
WHERE l.activity_id = ?
|
||||
`
|
||||
if startTime != nil {
|
||||
queryRevenue += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
|
||||
orderQuery = orderQuery.Where("created_at >= ?", startTime)
|
||||
}
|
||||
if endTime != nil {
|
||||
queryRevenue += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
|
||||
orderQuery = orderQuery.Where("created_at <= ?", endTime)
|
||||
}
|
||||
queryRevenue += ") as distinct_orders"
|
||||
|
||||
_ = h.repo.GetDbR().Raw(queryRevenue, id).Row().Scan(&totalRevenue, &orderCount)
|
||||
|
||||
// 统计退款:来自已参与过抽奖且标记为退款的订单 (order_status = 4)
|
||||
var totalRefund, refundCount int64
|
||||
queryRefund := `
|
||||
SELECT
|
||||
CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as ref_amt,
|
||||
COUNT(*) as ref_cnt
|
||||
FROM (
|
||||
SELECT DISTINCT o.id, o.actual_pay_amount
|
||||
FROM douyin_orders o
|
||||
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
|
||||
WHERE l.activity_id = ? AND o.order_status = 4
|
||||
`
|
||||
if startTime != nil {
|
||||
queryRefund += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
|
||||
var orderRefs []orderRef
|
||||
if err := orderQuery.Group("douyin_order_id").Scan(&orderRefs).Error; err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||||
return
|
||||
}
|
||||
if endTime != nil {
|
||||
queryRefund += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
|
||||
}
|
||||
queryRefund += ") as distinct_orders"
|
||||
|
||||
_ = h.repo.GetDbR().Raw(queryRefund, id).Row().Scan(&totalRefund, &refundCount)
|
||||
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
|
||||
@ -138,141 +183,130 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
|
||||
db.Find(&drawLogs)
|
||||
|
||||
// 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本
|
||||
refundedShopOrderIDs := make(map[string]bool)
|
||||
var refundedOrders []string
|
||||
qRefundIDs := `
|
||||
SELECT DISTINCT o.shop_order_id
|
||||
FROM douyin_orders o
|
||||
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
|
||||
WHERE l.activity_id = ? AND o.order_status = 4
|
||||
`
|
||||
// 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 {
|
||||
qRefundIDs += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
|
||||
invDB = invDB.Where("created_at >= ?", startTime.Add(-2*time.Hour))
|
||||
}
|
||||
if endTime != nil {
|
||||
qRefundIDs += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
|
||||
invDB = invDB.Where("created_at <= ?", endTime.Add(24*time.Hour))
|
||||
}
|
||||
h.repo.GetDbR().Raw(qRefundIDs, id).Scan(&refundedOrders)
|
||||
for _, oid := range refundedOrders {
|
||||
refundedShopOrderIDs[oid] = true
|
||||
}
|
||||
|
||||
// 4. 计算成本(使用 draw_logs.product_id 直接关联 products 表)
|
||||
// 收集未退款订单的 product_id 和对应数量
|
||||
productIDCountMap := make(map[int64]int64)
|
||||
for _, log := range drawLogs {
|
||||
// 排除已退款的订单
|
||||
if refundedShopOrderIDs[log.ShopOrderID] {
|
||||
continue
|
||||
}
|
||||
// 使用 draw_logs 中记录的 product_id
|
||||
if log.ProductID > 0 {
|
||||
productIDCountMap[log.ProductID]++
|
||||
_ = invDB.Scan(&inventories).Error
|
||||
for _, inv := range inventories {
|
||||
invByUser[inv.UserID] = append(invByUser[inv.UserID], inv)
|
||||
}
|
||||
}
|
||||
|
||||
var totalCost int64
|
||||
productCostMap := make(map[int64]int64)
|
||||
if len(productIDCountMap) > 0 {
|
||||
productIDs := make([]int64, 0, len(productIDCountMap))
|
||||
for pid := range productIDCountMap {
|
||||
productIDs = append(productIDs, pid)
|
||||
}
|
||||
|
||||
var products []model.Products
|
||||
h.repo.GetDbR().Where("id IN ?", productIDs).Find(&products)
|
||||
for _, p := range products {
|
||||
productCostMap[p.ID] = p.Price
|
||||
}
|
||||
|
||||
for productID, count := range productIDCountMap {
|
||||
if cost, ok := productCostMap[productID]; ok {
|
||||
totalCost += cost * count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 productID -> cost 映射供每日统计使用
|
||||
prizeCostMap := productCostMap
|
||||
|
||||
// 5. 按天分组统计
|
||||
dailyMap := make(map[string]*dailyLivestreamStats)
|
||||
|
||||
// 5.1 统计每日营收和退款(直接累加订单实付金额)
|
||||
type DailyAmount struct {
|
||||
type logRef struct {
|
||||
PrizeID int64
|
||||
DateKey string
|
||||
Amount int64
|
||||
Count int64
|
||||
IsRefunded int32
|
||||
}
|
||||
var dailyAmounts []DailyAmount
|
||||
queryDailyCorrect := `
|
||||
SELECT
|
||||
date_key,
|
||||
CAST(SUM(actual_pay_amount) AS SIGNED) as amount,
|
||||
COUNT(id) as cnt,
|
||||
refund_flag as is_refunded
|
||||
FROM (
|
||||
SELECT
|
||||
o.id,
|
||||
DATE_FORMAT(MIN(l.created_at), '%Y-%m-%d') as date_key,
|
||||
o.actual_pay_amount,
|
||||
IF(o.order_status = 4, 1, 0) as refund_flag
|
||||
FROM douyin_orders o
|
||||
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
|
||||
WHERE l.activity_id = ?
|
||||
`
|
||||
if startTime != nil {
|
||||
queryDailyCorrect += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
|
||||
}
|
||||
if endTime != nil {
|
||||
queryDailyCorrect += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
|
||||
}
|
||||
queryDailyCorrect += `
|
||||
GROUP BY o.id
|
||||
) as t
|
||||
GROUP BY date_key, is_refunded
|
||||
`
|
||||
rows, _ := h.repo.GetDbR().Raw(queryDailyCorrect, id).Rows()
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var da DailyAmount
|
||||
_ = rows.Scan(&da.DateKey, &da.Amount, &da.Count, &da.IsRefunded)
|
||||
dailyAmounts = append(dailyAmounts, da)
|
||||
}
|
||||
|
||||
for _, da := range dailyAmounts {
|
||||
if _, ok := dailyMap[da.DateKey]; !ok {
|
||||
dailyMap[da.DateKey] = &dailyLivestreamStats{Date: da.DateKey}
|
||||
}
|
||||
|
||||
// 修正口径:营收(Revenue) = 总流水 (含退款与未退款)
|
||||
// 这样下面的 NetProfit = TotalRevenue - TotalRefund - TotalCost 才不会双重扣除
|
||||
dailyMap[da.DateKey].TotalRevenue += da.Amount
|
||||
dailyMap[da.DateKey].OrderCount += da.Count
|
||||
|
||||
if da.IsRefunded == 1 {
|
||||
dailyMap[da.DateKey].TotalRefund += da.Amount
|
||||
dailyMap[da.DateKey].RefundCount += da.Count
|
||||
}
|
||||
}
|
||||
|
||||
// 5.2 统计每日成本(基于 Logs 的 ProductID)
|
||||
logsByKey := make(map[string][]logRef)
|
||||
keyUser := make(map[string]int64)
|
||||
keyOrder := make(map[string]string)
|
||||
for _, log := range drawLogs {
|
||||
// 排除退款订单
|
||||
if refundedShopOrderIDs[log.ShopOrderID] {
|
||||
continue
|
||||
}
|
||||
if log.ProductID <= 0 {
|
||||
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
|
||||
}
|
||||
dateKey := log.CreatedAt.Format("2006-01-02")
|
||||
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 {
|
||||
if cost, ok := prizeCostMap[log.ProductID]; ok {
|
||||
ds.TotalCost += cost
|
||||
}
|
||||
if ds == nil {
|
||||
ds = &dailyLivestreamStats{Date: dateKey}
|
||||
dailyMap[dateKey] = ds
|
||||
}
|
||||
ds.TotalCost += c
|
||||
}
|
||||
|
||||
// 6. 汇总每日数据并计算总体指标
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user